// Copyright 2022 The Chromium Authors
// 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.customtabs.features.partialcustomtab;
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import static androidx.browser.customtabs.CustomTabsCallback.ACTIVITY_LAYOUT_STATE_FULL_SCREEN;
import static androidx.browser.customtabs.CustomTabsCallback.ACTIVITY_LAYOUT_STATE_SIDE_SHEET;
import static androidx.browser.customtabs.CustomTabsCallback.ACTIVITY_LAYOUT_STATE_SIDE_SHEET_MAXIMIZED;
import static androidx.browser.customtabs.CustomTabsIntent.ACTIVITY_SIDE_SHEET_DECORATION_TYPE_DIVIDER;
import static androidx.browser.customtabs.CustomTabsIntent.ACTIVITY_SIDE_SHEET_DECORATION_TYPE_NONE;
import static androidx.browser.customtabs.CustomTabsIntent.ACTIVITY_SIDE_SHEET_DECORATION_TYPE_SHADOW;
import static androidx.browser.customtabs.CustomTabsIntent.ACTIVITY_SIDE_SHEET_POSITION_START;
import static androidx.browser.customtabs.CustomTabsIntent.ACTIVITY_SIDE_SHEET_ROUNDED_CORNERS_POSITION_NONE;
import android.animation.ValueAnimator;
import android.animation.ValueAnimator.AnimatorUpdateListener;
import android.app.Activity;
import android.graphics.drawable.GradientDrawable;
import android.os.Build;
import android.os.Handler;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityEvent;
import androidx.annotation.Px;
import androidx.annotation.StringRes;
import androidx.annotation.VisibleForTesting;
import androidx.browser.customtabs.CustomTabsCallback;
import androidx.browser.customtabs.CustomTabsIntent;
import org.chromium.base.MathUtils;
import org.chromium.base.SysUtils;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.browserservices.intents.BrowserServicesIntentDataProvider;
import org.chromium.chrome.browser.customtabs.CustomTabIntentDataProvider;
import org.chromium.chrome.browser.customtabs.features.toolbar.CustomTabToolbar;
import org.chromium.chrome.browser.fullscreen.FullscreenManager;
import org.chromium.ui.accessibility.AccessibilityState;
import org.chromium.ui.base.LocalizationUtils;
/**
* CustomTabHeightStrategy for Partial Custom Tab Side-Sheet implementation. An instance of this
* class should be owned by the CustomTabActivity.
*/
public class PartialCustomTabSideSheetStrategy extends PartialCustomTabBaseStrategy {
private static final int WINDOW_WIDTH_EXPANDED_CUTOFF_DP = 840;
private static final int SIDE_SHEET_UI_DELAY = 20;
private static final float MINIMAL_WIDTH_RATIO_EXPANDED = 0.33f;
private static final float MINIMAL_WIDTH_RATIO_MEDIUM = 0.5f;
private static final NoAnimator NO_ANIMATOR = new NoAnimator();
private final @Px int mUnclampedInitialWidth;
private final boolean mShowMaximizeButton;
private final int mRoundedCornersPosition;
private boolean mIsMaximized;
private int mDecorationType;
private boolean mSlideDownAnimation; // Slide down to bottom when closing the sheet.
private boolean mSheetOnRight;
public PartialCustomTabSideSheetStrategy(
Activity activity,
BrowserServicesIntentDataProvider intentData,
CustomTabHeightStrategy.OnResizedCallback onResizedCallback,
CustomTabHeightStrategy.OnActivityLayoutCallback onActivityLayoutCallback,
FullscreenManager fullscreenManager,
boolean isTablet,
boolean startMaximized,
PartialCustomTabHandleStrategyFactory handleStrategyFactory) {
super(
activity,
intentData,
onResizedCallback,
onActivityLayoutCallback,
fullscreenManager,
isTablet,
handleStrategyFactory);
mUnclampedInitialWidth = intentData.getInitialActivityWidth();
mShowMaximizeButton = intentData.showSideSheetMaximizeButton();
mPositionUpdater = this::updatePosition;
mDecorationType = intentData.getActivitySideSheetDecorationType();
mRoundedCornersPosition = intentData.getActivitySideSheetRoundedCornersPosition();
mIsMaximized = startMaximized;
mSheetOnRight = isSheetOnRight(intentData.getSideSheetPosition());
mSlideDownAnimation =
intentData.getSideSheetSlideInBehavior()
== CustomTabIntentDataProvider.ACTIVITY_SIDE_SHEET_SLIDE_IN_FROM_BOTTOM;
setupAnimator();
}
/**
* Return {@code true} if the sheet will be positioned on the right side of the window.
* @param sheetPosition Sheet position from the launching Intent.
*/
public static boolean isSheetOnRight(int sheetPosition) {
// Take RTL and position extra from the Intent (start or end) into account to determine
// the right side (right or left).
boolean isRtl = LocalizationUtils.isLayoutRtl();
boolean isAtStart = sheetPosition == ACTIVITY_SIDE_SHEET_POSITION_START;
return !(isRtl ^ isAtStart);
}
@Override
public @PartialCustomTabType int getStrategyType() {
return PartialCustomTabType.SIDE_SHEET;
}
@Override
public @StringRes int getTypeStringId() {
return R.string.accessibility_partial_custom_tab_side_sheet;
}
@Override
public void onShowSoftInput(Runnable softKeyboardRunnable) {
softKeyboardRunnable.run();
}
@Override
public boolean handleCloseAnimation(Runnable finishRunnable) {
if (!super.handleCloseAnimation(finishRunnable)) return false;
configureLayoutBeyondScreen(true);
Window window = mActivity.getWindow();
AnimatorUpdateListener closeAnimation;
int start;
int end;
if (mSlideDownAnimation) {
start = window.getAttributes().y;
end = mVersionCompat.getDisplayHeight();
closeAnimation = (animator) -> setWindowY((int) animator.getAnimatedValue());
} else {
start = window.getAttributes().x;
end = mSheetOnRight ? mVersionCompat.getScreenWidth() : -window.getAttributes().width;
closeAnimation = (animator) -> setWindowX((int) animator.getAnimatedValue());
}
startAnimation(start, end, closeAnimation, this::onCloseAnimationEnd, true);
return true;
}
@Override
public void onToolbarInitialized(
View coordinatorView, CustomTabToolbar toolbar, @Px int toolbarCornerRadius) {
super.onToolbarInitialized(coordinatorView, toolbar, toolbarCornerRadius);
if (mShowMaximizeButton) {
toolbar.initSideSheetMaximizeButton(mIsMaximized, () -> toggleMaximize(true));
}
toolbar.setMinimizeButtonEnabled(false);
updateDragBarVisibility(/* dragHandlebarVisibility= */ View.GONE);
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
boolean toggleMaximize(boolean animate) {
mIsMaximized = !mIsMaximized;
if (mIsMaximized) {
if (shouldDrawDividerLine()) resetCoordinatorLayoutInsets();
setTopMargins(0, 0);
}
AnimatorUpdateListener updateListener;
WindowManager.LayoutParams windowLayout = mActivity.getWindow().getAttributes();
int displayWidth = mVersionCompat.getDisplayWidth();
int clampedInitialWidth = calculateWidth(mUnclampedInitialWidth);
int start;
int end;
if (mSheetOnRight) {
configureLayoutBeyondScreen(true);
setWindowWidth(displayWidth);
int xOffset = mVersionCompat.getXOffset();
start = windowLayout.x;
end = (mIsMaximized ? 0 : displayWidth - clampedInitialWidth) + xOffset;
updateListener = (anim) -> setWindowX((int) anim.getAnimatedValue());
} else {
start = windowLayout.width;
end = mIsMaximized ? displayWidth : clampedInitialWidth;
View content = mActivity.findViewById(R.id.compositor_view_holder);
updateListener =
(anim) -> {
// Switch the invisibility type to GONE to prevent sluggish resizing
// artifacts.
if (content.getVisibility() != View.GONE) content.setVisibility(View.GONE);
setWindowWidth((int) anim.getAnimatedValue());
};
}
// Keep the WebContents invisible during the animation to hide the jerky visual artifacts
// of the contents due to resizing.
setContentVisible(false);
startAnimation(start, end, updateListener, this::onMaximizeEnd, animate);
return mIsMaximized;
}
private void setContentVisible(boolean visible) {
View content = mActivity.findViewById(R.id.compositor_view_holder);
if (visible) {
// Set a slight delay in restoring the view to hide the visual glitch caused by
// the resized web contents.
new Handler()
.postDelayed(() -> content.setVisibility(View.VISIBLE), SIDE_SHEET_UI_DELAY);
} else {
content.setVisibility(View.INVISIBLE);
}
}
private void onMaximizeEnd() {
setContentVisible(false);
if (isMaximized()) {
if (mSheetOnRight) configureLayoutBeyondScreen(false);
maybeResetFocusForScreenReaders();
maybeInvokeResizeCallback();
setContentVisible(true);
} else {
// System UI dimensions are not settled yet. Post the task.
new Handler()
.post(
() -> {
if (mSheetOnRight) configureLayoutBeyondScreen(false);
maybeResetFocusForScreenReaders();
initializeSize();
if (shouldDrawDividerLine()) drawDividerLine();
// We have a delay before showing the resized web contents so it has
// to be done for the shadow as well.
new Handler()
.postDelayed(this::updateShadowOffset, SIDE_SHEET_UI_DELAY);
maybeInvokeResizeCallback();
});
}
}
private void maybeResetFocusForScreenReaders() {
if (AccessibilityState.isScreenReaderEnabled()) {
// After resizing the view, notify the window state change to let screen reader
// focus navigation work as before.
mToolbarView.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED);
new Handler()
.postDelayed(
() -> {
// Move the focus and accessibility focus from the leftmost button
// back to maximize the button. This happens when double-tapping
// on the button causes the sheet to be resized to full width when
// a screen reader is running. Some delay
// is required for this to work as expected.
var maximizeButton =
mToolbarView.findViewById(
R.id.custom_tabs_sidepanel_maximize);
maximizeButton.sendAccessibilityEvent(
AccessibilityEvent.TYPE_VIEW_FOCUSED);
maximizeButton.sendAccessibilityEvent(
AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);
},
200);
}
}
@Override
protected boolean isMaximized() {
return mIsMaximized;
}
@Override
protected int getHandleHeight() {
return isFullscreen()
|| mRoundedCornersPosition
== CustomTabsIntent
.ACTIVITY_SIDE_SHEET_ROUNDED_CORNERS_POSITION_NONE
? 0
: mToolbarCornerRadius;
}
@Override
protected boolean isFullHeight() {
return false;
}
@Override
protected void updatePosition() {
if (isFullscreen() || mActivity.findViewById(android.R.id.content) == null) return;
initializeSize();
updateShadowOffset();
maybeInvokeResizeCallback();
}
@Override
protected void setTopMargins(int shadowOffset, int handleOffset) {
int leftMargin = mSheetOnRight ? shadowOffset : 0;
int rightMargin = !mSheetOnRight ? shadowOffset : 0;
float elevation = calculateElevation();
ViewGroup coordinatorLayout = mActivity.findViewById(R.id.coordinator);
coordinatorLayout.setElevation(elevation);
View handleView = mActivity.findViewById(R.id.custom_tabs_handle_view);
if (handleView != null) {
handleView.setElevation(elevation);
}
if (handleView != null) {
ViewGroup.MarginLayoutParams lp =
(ViewGroup.MarginLayoutParams) handleView.getLayoutParams();
lp.setMargins(leftMargin, 0, rightMargin, 0);
}
// Make enough room for the handle View.
int topOffset = Math.max(handleOffset - shadowOffset, 0);
ViewGroup.MarginLayoutParams mlp =
(ViewGroup.MarginLayoutParams) mToolbarCoordinator.getLayoutParams();
mlp.setMargins(leftMargin, topOffset, rightMargin, 0);
}
@Override
protected boolean shouldHaveNoShadowOffset() {
// We remove shadow in maximized mode.
return isMaximized()
|| calculateWidth(mUnclampedInitialWidth) == mVersionCompat.getDisplayWidth()
|| mDecorationType == ACTIVITY_SIDE_SHEET_DECORATION_TYPE_NONE
|| mDecorationType == ACTIVITY_SIDE_SHEET_DECORATION_TYPE_DIVIDER;
}
@Override
protected void adjustCornerRadius(GradientDrawable d, int radius) {
if (mRoundedCornersPosition == ACTIVITY_SIDE_SHEET_ROUNDED_CORNERS_POSITION_NONE) {
radius = 0;
}
int topLeftCornerRadius = mSheetOnRight ? radius : 0;
int topRightCornerRadius = !mSheetOnRight ? radius : 0;
View handleView = mActivity.findViewById(R.id.custom_tabs_handle_view);
View dragBar = handleView.findViewById(R.id.drag_bar);
ViewGroup.LayoutParams dragBarLayoutParams = dragBar.getLayoutParams();
dragBarLayoutParams.height = radius;
dragBar.setLayoutParams(dragBarLayoutParams);
d.mutate();
// Inner top rounded corner (depends on side sheet positioning)
d.setCornerRadii(
new float[] {
topLeftCornerRadius,
topLeftCornerRadius,
topRightCornerRadius,
topRightCornerRadius,
0,
0,
0,
0
});
}
@Override
protected void cleanupImeStateCallback() {
mVersionCompat.setImeStateCallback(null);
}
@Override
protected @CustomTabsCallback.ActivityLayoutState int getActivityLayoutState() {
if (isFullscreen()) {
return ACTIVITY_LAYOUT_STATE_FULL_SCREEN;
} else if (isMaximized()) {
return ACTIVITY_LAYOUT_STATE_SIDE_SHEET_MAXIMIZED;
} else {
return ACTIVITY_LAYOUT_STATE_SIDE_SHEET;
}
}
// ValueAnimator used when no animation should run. Simply lets the animator listener
// receive only the final value to skip the animation effect.
private static class NoAnimator extends ValueAnimator {
private int mValue;
private ValueAnimator withValue(int value) {
mValue = value;
return this;
}
@Override
public Object getAnimatedValue() {
return mValue;
}
}
private void startAnimation(
int start,
int end,
AnimatorUpdateListener updateListener,
Runnable endRunnable,
boolean animate) {
if (animate) {
startAnimation(start, end, updateListener, endRunnable);
} else {
updateListener.onAnimationUpdate(NO_ANIMATOR.withValue(end));
endRunnable.run();
}
}
@Override
protected void initializeSize() {
initializeHeight();
positionOnWindow();
setCoordinatorLayoutHeight(MATCH_PARENT);
updateDragBarVisibility(/* dragHandlebarVisibility= */ View.GONE);
if (mIsMaximized) {
mIsMaximized = false;
toggleMaximize(/* animate= */ false);
}
setContentVisible(true);
}
private void positionOnWindow() {
WindowManager.LayoutParams attrs = mActivity.getWindow().getAttributes();
attrs.height = mDisplayHeight - mStatusbarHeight - mNavbarHeight;
attrs.width = calculateWidth(mUnclampedInitialWidth);
attrs.y = mStatusbarHeight;
attrs.x =
(mSheetOnRight ? mVersionCompat.getDisplayWidth() - attrs.width : 0)
+ mVersionCompat.getXOffset();
attrs.gravity = Gravity.TOP | Gravity.START;
mActivity.getWindow().setAttributes(attrs);
}
private int calculateWidth(int unclampedWidth) {
int displayWidth = mVersionCompat.getDisplayWidth();
int displayWidthDp = mVersionCompat.getDisplayWidthDp();
float minWidthRatio =
displayWidthDp < WINDOW_WIDTH_EXPANDED_CUTOFF_DP
? MINIMAL_WIDTH_RATIO_MEDIUM
: MINIMAL_WIDTH_RATIO_EXPANDED;
return MathUtils.clamp(unclampedWidth, displayWidth, (int) (displayWidth * minWidthRatio));
}
private float calculateElevation() {
int width = calculateWidth(mUnclampedInitialWidth);
int displayWidth = mVersionCompat.getDisplayWidth();
// Shadows grow depending on size of activity, which is undesirable for this purpose
// To keep the shadow size consistent, we stratify the elevation according to the width.
if (width >= (displayWidth * 3 / 4)) {
// Side Sheet > 75% of screen
return 5;
} else if (width >= displayWidth / 2) {
// Side Sheet between 75% and 50% of screen
return 7;
} else if (width > displayWidth / 3) {
// Side Sheet between 33% and 50% of screen
return 9;
} else {
// 33% min-width Side Sheet
return 11;
}
}
@Override
protected void drawDividerLine() {
int width =
mActivity.getResources().getDimensionPixelSize(R.dimen.custom_tabs_outline_width);
int leftDividerInset = mSheetOnRight ? width : 0;
int rightDividerInset = !mSheetOnRight ? width : 0;
drawDividerLineBase(leftDividerInset, 0, rightDividerInset);
}
@Override
protected boolean shouldDrawDividerLine() {
boolean notMaxWidthSideSheet =
calculateWidth(mUnclampedInitialWidth) != mVersionCompat.getDisplayWidth();
// Elevation shadows are only rendered properly on devices >= Android Q
return notMaxWidthSideSheet
&& (SysUtils.isLowEndDevice()
|| mDecorationType == ACTIVITY_SIDE_SHEET_DECORATION_TYPE_DIVIDER
|| (mDecorationType == ACTIVITY_SIDE_SHEET_DECORATION_TYPE_SHADOW
&& Build.VERSION.SDK_INT < Build.VERSION_CODES.Q));
}
@Override
public void destroy() {
super.destroy();
if (mShowMaximizeButton) ((CustomTabToolbar) mToolbarView).removeSideSheetMaximizeButton();
}
void setSlideDownAnimationForTesting(boolean slideDown) {
mSlideDownAnimation = slideDown;
}
void setSheetOnRightForTesting(boolean right) {
mSheetOnRight = right;
}
}