blob: 9d362324cb1abf4c25cadce6c32db12700fca63b [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.weblayer_private;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Rect;
import android.view.View;
import android.view.ViewParent;
import android.widget.FrameLayout;
import androidx.annotation.Nullable;
import org.chromium.base.MathUtils;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.annotations.JNINamespace;
import org.chromium.base.annotations.NativeMethods;
import org.chromium.cc.input.BrowserControlsState;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.base.EventOffsetHandler;
import org.chromium.ui.resources.dynamics.ViewResourceAdapter;
/**
* BrowserControlsContainerView is responsible for positioning and sizing a view from the client
* that is anchored to the top or bottom of a Browser. BrowserControlsContainerView, uses
* ViewResourceAdapter that is kept in sync with the contents of the view. ViewResourceAdapter is
* used to keep a bitmap in sync with the contents of the view. The bitmap is placed in a cc::Layer
* and the layer is shown while scrolling. ViewResourceAdapter is always kept in sync, as to do
* otherwise results in a noticeable delay between when the scroll starts the content is available.
*
* There are many parts involved in orchestrating scrolling. The key things to know are:
* . BrowserControlsContainerView (in native code) keeps a cc::Layer that shows a bitmap rendered by
* the view. The bitmap is updated anytime the view changes. This is done as otherwise there is a
* noticeable delay between when the scroll starts and the bitmap is available.
* . When scrolling, the cc::Layer for the WebContents and BrowserControlsContainerView is moved.
* . The size of the WebContents is only changed after the user releases a touch point. Otherwise
* the scrollbar bounces around.
* . WebContentsDelegate::DoBrowserControlsShrinkRendererSize() only changes when the WebContents
* size change.
* . WebContentsGestureStateTracker is responsible for determining when a scroll/touch is underway.
* . ContentViewRenderView.Delegate is used to adjust the size of the webcontents when the
* controls are fully visible (and a scroll is not underway).
*
* The flow of this code is roughly:
* . WebContentsGestureStateTracker generally detects a touch first
* . TabImpl is notified and caches state.
* . onTop/BottomControlsChanged() is called. This triggers hiding the real view and calling to
* native code to move the cc::Layers.
* . the move continues.
* . when the move completes and both WebContentsGestureStateTracker and
* BrowserControlsContainerView no longer believe a move/gesture/scroll is underway the size of the
* WebContents is adjusted (if necessary).
*/
@JNINamespace("weblayer")
class BrowserControlsContainerView extends FrameLayout {
// ID used with ViewResourceAdapter.
private static final int TOP_CONTROLS_ID = 1001;
private static final int BOTTOM_CONTROLS_ID = 1002;
private static final long SYSTEM_UI_VIEWPORT_UPDATE_DELAY_MS = 500;
private static final int DEFAULT_LAST_SHOWN_AMOUNT = -1;
/** Stores the state needed to reconstruct offsets after recreating this class. */
/* package */ static class State {
private final int mControlsOffset;
private final int mContentOffset;
private State(int controlsOffset, int contentOffset) {
mControlsOffset = controlsOffset;
mContentOffset = contentOffset;
}
}
private final Delegate mDelegate;
private final boolean mIsTop;
// The state returned by a previous BrowserControlsContainerView instance's getState() method.
// This is saved rather than directly applied because layout needs to occur before we can apply
// the offsets.
private State mSavedState;
private long mNativeBrowserControlsContainerView;
private ViewResourceAdapter mViewResourceAdapter;
// Last width/height of mView as sent to the native side.
private int mLastWidth;
private int mLastHeight;
// View from the client.
private View mView;
private ContentViewRenderView mContentViewRenderView;
private WebContents mWebContents;
// Only created when hosting top-controls.
private EventOffsetHandler mEventOffsetHandler;
// Amount page content is offset along the y-axis. This is always 0 for bottom controls (because
// bottom controls don't offset content). For top controls, a value of 0 means no offset, and
// positive values indicate a portion of the top-control is shown. This value never goes
// negative.
private int mContentOffset;
// Amount the control is offset along the y-axis from it's fully shown position. For
// top-controls, the value ranges from 0 (completely shown) to -height (completely hidden). For
// bottom-controls, the value ranges from 0 (completely shown) to height (completely hidden).
private int mControlsOffset;
// Stores how much of the controls in pixels is visible when a view is set. This does NOT get
// set to 0 when you remove a view; it always stores the most recent control visibility amount
// as of the last time a view was actually set, or a default value. This is used to help mimic
// the positioning behavior of the renderer.
private int mLastShownAmountWithView = DEFAULT_LAST_SHOWN_AMOUNT;
// The minimum height that the controls should collapse to. Only used for top controls.
private int mMinHeight;
// Whether the controls should only expand when the page is scrolled to the top. Only used for
// top controls.
private boolean mOnlyExpandControlsAtPageTop;
// Set to true if changes to the controls height or offset should be animated.
private boolean mShouldAnimate;
// Set to true if |mView| is hidden because the user has scrolled or triggered some action such
// that mView is not visible. While |mView| is not visible if this is true, the bitmap from
// |mView| may be partially visible.
private boolean mInScroll;
// Set to true while we are animating the controls off the screen after removing them.
private boolean mAnimatingOut;
private boolean mIsFullscreen;
// Used to delay processing fullscreen requests.
private Runnable mSystemUiFullscreenResizeRunnable;
// Used to delay updating the image for the layer.
private final Runnable mRefreshResourceIdRunnable = () -> {
if (mView == null || mViewResourceAdapter == null) return;
BrowserControlsContainerViewJni.get().updateControlsResource(
mNativeBrowserControlsContainerView);
};
// Used to delay hiding the controls.
private final Runnable mHideControlsRunnable = this::hideControlsNow;
// Used to delay showing the controls.
private final Runnable mShowControlsRunnable = this::showControlsNow;
public interface Delegate {
/**
* Requests that the page height be recalculated due to browser controls height changes.
*/
void refreshPageHeight();
/**
* Requests that the browser controls visibility state be changed.
*/
void setAnimationConstraint(@BrowserControlsState int constraint);
/**
* Called when the offset of the controls changes.
*/
void onOffsetsChanged(boolean isTop, int controlsOffset);
}
BrowserControlsContainerView(Context context, ContentViewRenderView contentViewRenderView,
Delegate delegate, boolean isTop, @Nullable State savedState) {
super(context);
mDelegate = delegate;
mIsTop = isTop;
mSavedState = savedState;
mContentViewRenderView = contentViewRenderView;
mNativeBrowserControlsContainerView =
BrowserControlsContainerViewJni.get().createBrowserControlsContainerView(
this, contentViewRenderView.getNativeHandle(), isTop);
}
public void setWebContents(WebContents webContents) {
mWebContents = webContents;
BrowserControlsContainerViewJni.get().setWebContents(
mNativeBrowserControlsContainerView, webContents);
cancelDelayedFullscreenRunnable();
if (mWebContents == null) return;
processFullscreenChanged(mWebContents.isFullscreenForCurrentTab());
}
public void destroy() {
if (mIsTop) setAnimationsEnabled(false);
setView(null);
BrowserControlsContainerViewJni.get().deleteBrowserControlsContainerView(
mNativeBrowserControlsContainerView);
cancelDelayedFullscreenRunnable();
}
public long getNativeHandle() {
return mNativeBrowserControlsContainerView;
}
public EventOffsetHandler getEventOffsetHandler() {
assert mIsTop;
if (mEventOffsetHandler == null) {
mEventOffsetHandler =
new EventOffsetHandler(new EventOffsetHandler.EventOffsetHandlerDelegate() {
@Override
public float getTop() {
return mContentOffset;
}
@Override
public void setCurrentTouchEventOffsets(float top) {
if (mWebContents != null) {
mWebContents.getEventForwarder().setCurrentTouchEventOffsets(
0, top);
}
}
});
}
return mEventOffsetHandler;
}
/**
* Returns the amount of vertical space to take away from the contents.
*/
public int getContentHeightDelta() {
if (mView == null) return 0;
return mIsTop ? mContentOffset : mLastHeight - mControlsOffset;
}
/**
* Returns true if the browser control is visible to the user.
*/
public boolean isControlVisible() {
// Don't check the visibility of the View itself as it's hidden while scrolling.
return mView != null && Math.abs(mControlsOffset) != mLastHeight;
}
public void setAnimationsEnabled(boolean animationsEnabled) {
assert mIsTop;
mShouldAnimate = animationsEnabled;
}
/**
* Returns true if the controls are completely expanded or completely collapsed.
* "Completely collapsed" does not necessarily mean hidden; the controls could be at their min
* height, in which case this would return true. A return value of false indicates the controls
* are being moved.
*/
public boolean isCompletelyExpandedOrCollapsed() {
return mControlsOffset == 0 || Math.abs(mControlsOffset) == mLastHeight - mMinHeight;
}
/**
* Sets the view from the client.
*/
public void setView(View view) {
if (mView == view) return;
if (mView != null && mView.getParent() == this) removeView(mView);
mView = view;
if (mView == null) {
// If we're animating out the old view, leave the cc::Layer in place so it's visible
// during the animation, and set our visibility to HIDDEN, which will cause
// BrowserControlsOffsetManager to start an animation off the screen. getMinHeight()
// will return 0 while mAnimatingOut is true, so call reportHeightChange() to tell the
// renderer to grab the potentially new height.
if (mShouldAnimate && mControlsOffset != -mLastHeight) {
assert mIsTop; // mShouldAnimate should only be true for top controls.
mAnimatingOut = true;
reportHeightChange();
mDelegate.setAnimationConstraint(BrowserControlsState.HIDDEN);
mDelegate.refreshPageHeight();
} else {
destroyLayer();
}
return;
}
mAnimatingOut = false;
destroyLayer();
addView(view,
new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.UNSPECIFIED_GRAVITY));
// The controls will be positioned in onLayout, which will result in showControls or
// hideControls being called. hideControls may delay the hide by a frame, resulting in the
// View flashing during the current frame. To work around this, we hide the controls here.
// showControls will also delay the showing by a frame, but that doesn't cause a flash
// because the bitmap will be visible until the setVisibility call completes.
hideControlsNow();
mContentViewRenderView.removeCallbacks(mShowControlsRunnable);
mDelegate.setAnimationConstraint(BrowserControlsState.BOTH);
}
public View getView() {
return mView;
}
/**
* Sets the minimum height the controls can collapse to.
* Only valid for top controls.
*/
public void setMinHeight(int minHeight) {
assert mIsTop;
if (mMinHeight == minHeight) return;
mMinHeight = minHeight;
reportHeightChange();
}
/**
* Sets whether the controls should only expand at the top of the page contents.
* Only valid for top controls.
*/
public void setOnlyExpandControlsAtPageTop(boolean onlyExpandControlsAtPageTop) {
mOnlyExpandControlsAtPageTop = onlyExpandControlsAtPageTop;
}
public boolean getOnlyExpandControlsAtPageTop() {
return mOnlyExpandControlsAtPageTop;
}
/**
* Called from ViewAndroidDelegate, see it for details.
*/
public void onOffsetsChanged(int controlsOffsetY, int contentOffsetY) {
// Delete the cc::Layer if we reached the end of the animation off the screen.
if (mAnimatingOut && controlsOffsetY == -mLastHeight) {
mAnimatingOut = false;
destroyLayer();
reportHeightChange();
// Request a layout so onLayout can update the saved dimensions now that the
// layer has finished animating.
requestLayout();
}
if (mIsFullscreen) return;
setControlsOffset(controlsOffsetY, contentOffsetY);
if (mControlsOffset == 0
|| (mIsTop && getMinHeight() > 0
&& mControlsOffset == -mLastHeight + getMinHeight())) {
finishScroll();
} else if (!mInScroll) {
prepareForScroll();
}
}
@SuppressLint("NewApi") // Used on O+, invalidateChildInParent used for previous versions.
@Override
public void onDescendantInvalidated(View child, View target) {
super.onDescendantInvalidated(child, target);
invalidateViewResourceAdapter();
}
@Override
public ViewParent invalidateChildInParent(int[] location, Rect dirty) {
invalidateViewResourceAdapter();
return super.invalidateChildInParent(location, dirty);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
if (mAnimatingOut) return;
int width = right - left;
int height = bottom - top;
boolean heightChanged = height != mLastHeight;
if (!heightChanged && width == mLastWidth) return;
int prevHeight = mLastHeight;
mLastWidth = width;
mLastHeight = height;
if (mLastWidth > 0 && mLastHeight > 0 && mViewResourceAdapter == null) {
createAdapterAndLayer();
if (mIsFullscreen) {
// This calls setControlsOffset() as onOffsetsChanged() does (mostly) nothing when
// fullscreen.
setControlsOffset(mIsTop ? -mLastHeight : mLastHeight, 0);
} else if (mLastShownAmountWithView == DEFAULT_LAST_SHOWN_AMOUNT
&& mSavedState != null) {
// If there wasn't a View before and we have non-empty saved state from a previous
// BrowserControlsContainerView instance, apply those saved offsets now. We can't
// rely on BrowserControlsOffsetManager to notify us of the correct location as we
// usually do because it only notifies us when offsets change, but it likely didn't
// get destroyed when the BrowserFragment got recreated, so it won't notify us
// because it thinks we already have the correct offsets.
onOffsetsChanged(mSavedState.mControlsOffset, mSavedState.mContentOffset);
} else {
// Ideally we'd leave the controls hidden and wait for BrowserControlsOffsetManager
// to tell us where it wants them, but it communicates with us via frame metadata
// that is generated when the renderer's compositor submits a frame to viz, which is
// paused during page loads. Because of this, we might not get positioning
// information from the renderer for several seconds during page loads, so we need
// to attempt to position the controls ourselves here.
int targetShownAmount;
if (mShouldAnimate) {
// If animations are enabled, leave the amount of visible controls unchanged
// (e.g. hide them if there weren't previously controls or if they were hidden
// before).
targetShownAmount = mIsTop ? (prevHeight + mControlsOffset)
: (prevHeight - mControlsOffset);
} else {
// If animations are disabled, restore the positioning the controls had the last
// time they were non-null. This mimics the behavior of
// BrowserControlsOffsetManager in the renderer.
targetShownAmount = (mLastShownAmountWithView == DEFAULT_LAST_SHOWN_AMOUNT)
? height
: mLastShownAmountWithView;
}
onOffsetsChanged(
mIsTop ? (targetShownAmount - height) : (height - targetShownAmount),
mIsTop ? targetShownAmount : 0);
}
mSavedState = null;
} else if (mViewResourceAdapter != null) {
BrowserControlsContainerViewJni.get().setControlsSize(
mNativeBrowserControlsContainerView, mLastWidth, mLastHeight);
}
if (heightChanged) reportHeightChange();
}
@Override
public void onDetachedFromWindow() {
super.onDetachedFromWindow();
// Cancel the runnable when detached as calls to removeCallback() after this completes will
// attempt to remove from the wrong handler.
cancelDelayedFullscreenRunnable();
}
/* package */ State getState() {
return new State(mControlsOffset, mContentOffset);
}
private void cancelDelayedFullscreenRunnable() {
if (mSystemUiFullscreenResizeRunnable == null) return;
removeCallbacks(mSystemUiFullscreenResizeRunnable);
mSystemUiFullscreenResizeRunnable = null;
}
/**
* Triggers copying the contents of mView to the offscreen buffer.
*/
private void invalidateViewResourceAdapter() {
if (mViewResourceAdapter == null || mView.getVisibility() != View.VISIBLE) return;
mViewResourceAdapter.invalidate(null);
removeCallbacks(mRefreshResourceIdRunnable);
postOnAnimation(mRefreshResourceIdRunnable);
}
/**
* Creates mViewResourceAdapter and the layer showing a copy of mView.
*/
private void createAdapterAndLayer() {
assert mViewResourceAdapter == null;
assert mView != null;
mViewResourceAdapter = new ViewResourceAdapter(mView);
mContentViewRenderView.getResourceManager().getDynamicResourceLoader().registerResource(
getResourceId(), mViewResourceAdapter);
// It's important that the layer is created immediately and always kept in sync with the
// View. Creating the layer only when needed results in a noticeable delay between when
// the layer is created and actually shown. Chrome for Android does the same thing.
BrowserControlsContainerViewJni.get().createControlsLayer(
mNativeBrowserControlsContainerView, getResourceId());
BrowserControlsContainerViewJni.get().setControlsSize(
mNativeBrowserControlsContainerView, mLastWidth, mLastHeight);
}
/**
* Destroys the cc::Layer containing the bitmap copy of the View.
*/
private void destroyLayer() {
if (mViewResourceAdapter == null) return;
// TODO: need some sort of destroy to drop reference.
mViewResourceAdapter = null;
BrowserControlsContainerViewJni.get().deleteControlsLayer(
mNativeBrowserControlsContainerView);
mContentViewRenderView.getResourceManager().getDynamicResourceLoader().unregisterResource(
getResourceId());
}
private void setControlsOffset(int controlsOffsetY, int contentOffsetY) {
// This function is called asynchronously from the gpu, and may be out of sync with the
// current values.
if (mIsTop) {
// Don't snap to min-height because the controls could be animating in from a
// previously lower min-height.
mControlsOffset = MathUtils.clamp(controlsOffsetY, -mLastHeight, 0);
} else {
mControlsOffset = MathUtils.clamp(controlsOffsetY, 0, mLastHeight);
}
mContentOffset = MathUtils.clamp(contentOffsetY, 0, mLastHeight);
if (mView != null) {
mLastShownAmountWithView =
mIsTop ? (mLastHeight + mControlsOffset) : (mLastHeight - mControlsOffset);
}
if (isCompletelyExpandedOrCollapsed()) {
mDelegate.refreshPageHeight();
}
if (mIsTop) {
BrowserControlsContainerViewJni.get().setTopControlsOffset(
mNativeBrowserControlsContainerView, mContentOffset);
} else {
BrowserControlsContainerViewJni.get().setBottomControlsOffset(
mNativeBrowserControlsContainerView);
}
mDelegate.onOffsetsChanged(mIsTop, mControlsOffset);
}
private void reportHeightChange() {
if (mWebContents != null) {
mWebContents.notifyBrowserControlsHeightChanged();
}
}
private void prepareForScroll() {
mInScroll = true;
hideControls();
}
private void finishScroll() {
mInScroll = false;
showControls();
}
private void hideControls() {
if (BrowserControlsContainerViewJni.get().shouldDelayVisibilityChange()) {
mContentViewRenderView.postOnAnimation(mHideControlsRunnable);
} else {
hideControlsNow();
}
}
private void hideControlsNow() {
if (mView != null) {
mView.setVisibility(View.INVISIBLE);
}
}
private void showControls() {
if (BrowserControlsContainerViewJni.get().shouldDelayVisibilityChange()) {
mContentViewRenderView.postOnAnimation(mShowControlsRunnable);
} else {
showControlsNow();
}
}
private void showControlsNow() {
if (mView != null) {
if (mIsTop) {
mView.setTranslationY(mControlsOffset);
}
mView.setVisibility(View.VISIBLE);
}
}
@CalledByNative
/* package */ boolean shouldAnimateBrowserControlsHeightChanges() {
return mShouldAnimate;
}
@CalledByNative
private int getControlsOffset() {
return mControlsOffset;
}
@CalledByNative
private int getMinHeight() {
if (mAnimatingOut) return 0;
return Math.min(mLastHeight, mMinHeight);
}
@CalledByNative
private boolean onlyExpandControlsAtPageTop() {
return mOnlyExpandControlsAtPageTop;
}
@CalledByNative
private void didToggleFullscreenModeForTab(final boolean isFullscreen) {
// Delay hiding until after the animation. This comes from Chrome code.
if (mSystemUiFullscreenResizeRunnable != null) {
removeCallbacks(mSystemUiFullscreenResizeRunnable);
}
mSystemUiFullscreenResizeRunnable = () -> processFullscreenChanged(isFullscreen);
long delay = isFullscreen ? SYSTEM_UI_VIEWPORT_UPDATE_DELAY_MS : 0;
postDelayed(mSystemUiFullscreenResizeRunnable, delay);
}
private void processFullscreenChanged(boolean isFullscreen) {
mSystemUiFullscreenResizeRunnable = null;
if (mIsFullscreen == isFullscreen) return;
mIsFullscreen = isFullscreen;
if (mIsFullscreen) {
mAnimatingOut = false;
hideControls();
moveControlsOffScreen();
} else {
showControls();
setControlsOffset(0, mIsTop ? mLastHeight : 0);
}
}
private void moveControlsOffScreen() {
setControlsOffset(mIsTop ? -mLastHeight : mLastHeight, 0);
}
private int getResourceId() {
return mIsTop ? TOP_CONTROLS_ID : BOTTOM_CONTROLS_ID;
}
@NativeMethods
interface Natives {
long createBrowserControlsContainerView(
BrowserControlsContainerView view, long nativeContentViewRenderView, boolean isTop);
void deleteBrowserControlsContainerView(long nativeBrowserControlsContainerView);
void createControlsLayer(long nativeBrowserControlsContainerView, int id);
void deleteControlsLayer(long nativeBrowserControlsContainerView);
void setTopControlsOffset(long nativeBrowserControlsContainerView, int contentOffsetY);
void setBottomControlsOffset(long nativeBrowserControlsContainerView);
void setControlsSize(long nativeBrowserControlsContainerView, int width, int height);
void updateControlsResource(long nativeBrowserControlsContainerView);
void setWebContents(long nativeBrowserControlsContainerView, WebContents webContents);
boolean shouldDelayVisibilityChange();
}
}