// Copyright 2015 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.compositor.bottombar.contextualsearch;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.content.Context;
import android.os.Handler;
import android.text.method.LinkMovementMethod;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import org.chromium.base.MathUtils;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanel;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanelAnimation;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanelInflater;
import org.chromium.chrome.browser.compositor.bottombar.contextualsearch.ContextualSearchPanel.ContextualSearchPromoHost;
import org.chromium.chrome.browser.contextualsearch.ContextualSearchSettingsFragment;
import org.chromium.chrome.browser.contextualsearch.ContextualSearchUma;
import org.chromium.chrome.browser.layouts.animation.CompositorAnimator;
import org.chromium.chrome.browser.settings.SettingsLauncherFactory;
import org.chromium.chrome.browser.ui.theme.ChromeSemanticColorUtils;
import org.chromium.components.browser_ui.settings.SettingsLauncher;
import org.chromium.ui.base.LocalizationUtils;
import org.chromium.ui.base.ViewUtils;
import org.chromium.ui.resources.dynamics.DynamicResourceLoader;
import org.chromium.ui.text.NoUnderlineClickableSpan;
import org.chromium.ui.text.SpanApplier;
/**
* Controls the Contextual Search Opt-in/out privacy Promo that shows within the Panel just below
* the Bar for users that have not yet accepted or declined our privacy policy.
*/
public class ContextualSearchPromoControl extends OverlayPanelInflater {
// The percentage that inicates we've reached full size (for this mode) and are now stationary.
private static final float STATIONARY_PERCENTAGE = 1.0f;
// An arbitrary intermediate value in between 0 and 1 that indicates we're in transition.
private static final float INTERMEDIATE_PERCENTAGE = 0.5f;
/** The interface used to talk to the Panel. */
private final ContextualSearchPromoHost mHost;
/** The pixel density. */
private final float mDpToPx;
/** The background color of the promo. */
private final int mBackgroundColor;
/** Whether the Promo is visible. */
private boolean mIsVisible;
/** The opacity of the Promo. */
private float mOpacity;
/** The height of the Promo in pixels. */
private float mHeightPx;
/** The height of the Promo content in pixels. */
private float mContentHeightPx;
/** Whether the Promo View is showing. */
private boolean mIsShowingView;
/** The Y position of the Promo View. */
private float mPromoViewY;
/** Whether the Promo was in a state that could be interacted. */
private boolean mWasInteractive;
/** Whether the user's choice has been handled. */
private boolean mHasHandledChoice;
/**
* @param panel The panel.
* @param context The Android Context used to inflate the View.
* @param container The container View used to inflate the View.
* @param resourceLoader The resource loader that will handle the snapshot capturing.
*/
ContextualSearchPromoControl(
OverlayPanel panel,
ContextualSearchPromoHost host,
Context context,
ViewGroup container,
DynamicResourceLoader resourceLoader) {
super(
panel,
R.layout.contextual_search_promo_view,
R.id.contextual_search_promo,
context,
container,
resourceLoader);
mDpToPx = context.getResources().getDisplayMetrics().density;
mBackgroundColor =
ChromeSemanticColorUtils.getContextualSearchPromoBackgroundColor(context);
mHost = host;
}
// ============================================================================================
// Public API
// ============================================================================================
/** Shows the Promo. This includes inflating the View and setting its initial state. */
void show() {
if (mIsVisible) return;
// Invalidates the View in order to generate a snapshot, but do not show the View yet.
// The View should only be displayed when in the expanded state.
invalidate();
mIsVisible = true;
mWasInteractive = false;
mHeightPx = mContentHeightPx;
}
/** Hides the Promo */
void hide() {
if (!mIsVisible) return;
hidePromoView();
mIsVisible = false;
mHeightPx = 0.f;
mOpacity = 0.f;
}
/**
* Handles change in the Contextual Search preference state.
*
* @param isEnabled Whether the feature was enable.
*/
void onContextualSearchPrefChanged(boolean isEnabled) {
if (!mIsVisible || !mOverlayPanel.isShowing()) return;
collapse();
}
/**
* @return Whether the Promo is visible.
*/
public boolean isVisible() {
return mIsVisible;
}
/**
* @return Whether the Promo reached a state in which it could be interacted.
*/
public boolean wasInteractive() {
return mWasInteractive;
}
/**
* @return The Promo height in pixels.
*/
public float getHeightPx() {
return mHeightPx;
}
/**
* @return The Promo opacity.
*/
public float getOpacity() {
return mOpacity;
}
/**
* @return The background color for the promo, which controls areas outside the content.
*/
public int getBackgroundColor() {
return mBackgroundColor;
}
// ============================================================================================
// Panel Animation
// ============================================================================================
/**
* Interpolates the UI from states Closed to Peeked.
*
* @param percentage The completion percentage.
*/
public void onUpdateFromCloseToPeek(float percentage) {
if (!isVisible()) return;
// Promo snapshot should be fully visible here.
updateAppearance(1.f);
// The View should not be visible in this state.
hidePromoView();
}
/**
* Interpolates the UI from states Peeked to Expanded.
*
* @param percentage The completion percentage.
*/
public void onUpdateFromPeekToExpand(float percentage) {
if (!isVisible()) return;
// Promo snapshot should be fully visible here.
updateAppearance(1.f);
if (percentage == 1.f) {
// We should show the Promo View only when the Panel
// has reached the exact expanded height.
showPromoView();
} else {
// Otherwise the View should not be visible.
hidePromoView();
}
}
/**
* Interpolates the UI from states Expanded to Maximized.
*
* @param percentage The completion percentage.
*/
public void onUpdateFromExpandToMaximize(float percentage) {
if (!isVisible()) return;
// Promo snapshot collapses as the Panel reaches the maximized state.
updateAppearance(1.f - percentage);
// The View should not be visible in this state.
hidePromoView();
}
/** Notifies that movement of this panel section hast started or stopped. */
void onUpdateForMovement(boolean isMovementStarting) {
if (isMovementStarting) hidePromoView();
updateAppearance(isMovementStarting ? INTERMEDIATE_PERCENTAGE : STATIONARY_PERCENTAGE);
}
// ============================================================================================
// Promo Acceptance Animation
// ============================================================================================
/** Collapses the Promo in an animated fashion. */
public void collapse() {
hidePromoView();
// Notify the host that the content is moving so adjustments can be made (e.g. the Opt-in
// promo will be in motion if it's shown).
mHost.onPanelSectionSizeChange(true);
CompositorAnimator collapse =
CompositorAnimator.ofFloat(
mOverlayPanel.getAnimationHandler(),
1.f,
0.f,
OverlayPanelAnimation.BASE_ANIMATION_DURATION_MS,
null);
collapse.addUpdateListener(animator -> updateAppearance(animator.getAnimatedValue()));
collapse.addListener(
new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
hide();
mHost.onPanelSectionSizeChange(false);
}
});
collapse.start();
}
/**
* Updates the appearance of the Promo.
*
* @param percentage The completion percentage. 0.f means the Promo is fully collapsed and
* transparent. 1.f means the Promo is fully expanded and opaque.
*/
private void updateAppearance(float percentage) {
if (mIsVisible) {
mHeightPx =
Math.round(
MathUtils.clamp(percentage * mContentHeightPx, 0.f, mContentHeightPx));
mOpacity = percentage;
} else {
mHeightPx = 0.f;
mOpacity = 0.f;
}
}
// ============================================================================================
// Custom Behaviors
// ============================================================================================
@Override
public void destroy() {
hide();
super.destroy();
}
@Override
public void invalidate(boolean didViewSizeChange) {
super.invalidate(didViewSizeChange);
if (didViewSizeChange) {
onPromoViewSizeChange();
}
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
View view = getView();
// "Allow" button.
Button allowButton = view.findViewById(R.id.contextual_search_allow_button);
allowButton.setOnClickListener(
v -> ContextualSearchPromoControl.this.handlePromoChoice(true));
// "No thanks" button.
Button noThanksButton = view.findViewById(R.id.contextual_search_no_thanks_button);
noThanksButton.setOnClickListener(
v -> ContextualSearchPromoControl.this.handlePromoChoice(false));
// Fill in text with link to Settings.
TextView promoText = view.findViewById(R.id.contextual_search_promo_text);
NoUnderlineClickableSpan settingsLink =
new NoUnderlineClickableSpan(
view.getContext(),
(View ignored) ->
ContextualSearchPromoControl.this.handleClickSettingsLink());
promoText.setText(
SpanApplier.applySpans(
view.getResources().getString(R.string.contextual_search_promo_description),
new SpanApplier.SpanInfo("<link>", "</link>", settingsLink)));
promoText.setMovementMethod(LinkMovementMethod.getInstance());
onPromoViewSizeChange();
}
@Override
protected boolean shouldDetachViewAfterCapturing() {
return false;
}
// ============================================================================================
// Promo Interaction
// ============================================================================================
/**
* Handles the choice made by the user in the Promo.
*
* @param hasEnabled Whether the user has chosen to enable the feature.
*/
private void handlePromoChoice(boolean hasEnabled) {
if (!mHasHandledChoice) {
mHasHandledChoice = true;
mHost.setContextualSearchPromoCardSelection(hasEnabled);
ContextualSearchUma.logPromoCardChoice(hasEnabled);
}
}
/** Handles a click in the settings link located in the Promo. */
private void handleClickSettingsLink() {
new Handler()
.post(
new Runnable() {
@Override
public void run() {
SettingsLauncher settingsLauncher =
SettingsLauncherFactory.createSettingsLauncher();
settingsLauncher.launchSettingsActivity(
getContext(), ContextualSearchSettingsFragment.class);
}
});
}
// ============================================================================================
// Helpers
// ============================================================================================
/**
* Shows the Promo Android View. By making the Android View visible, we are allowing the Promo
* to be interactive. Since snapshots are not interactive (they are just a bitmap), we need to
* temporarily show the Android View on top of the snapshot, so the user will be able to click
* in the Promo buttons and/or link.
*/
private void showPromoView() {
float y = mHost.getYPositionPx();
View view = getView();
if (view == null
|| !mIsVisible
|| (mIsShowingView && mPromoViewY == y)
|| mHeightPx == 0.f) {
return;
}
float offsetX = mOverlayPanel.getOffsetX() * mDpToPx;
if (LocalizationUtils.isLayoutRtl()) {
offsetX = -offsetX;
}
view.setTranslationX(offsetX);
view.setTranslationY(y);
view.setVisibility(View.VISIBLE);
// NOTE(pedrosimonetti): We need to call requestLayout, otherwise
// the Promo View will not become visible.
ViewUtils.requestLayout(view, "ContextualSearchPromoControl.showPromoView");
mIsShowingView = true;
mPromoViewY = y;
// The Promo can only be interacted when the View is being displayed.
mWasInteractive = true;
mHost.onPromoShown();
updatePromoHeight();
}
/** Hides the Promo Android View. See {@link #showPromoView()}. */
private void hidePromoView() {
View view = getView();
if (view == null || !mIsVisible || !mIsShowingView) {
return;
}
view.setVisibility(View.INVISIBLE);
mIsShowingView = false;
}
/**
* This should be called whenever the the size of the Promo View changes or something inside the
* promo changes that could affect the overall size.
*/
private void onPromoViewSizeChange() {
layout();
updatePromoHeight();
}
/**
* Calculates the content height of the Promo View, and adjusts the height of the Promo while
* preserving the proportion of the height with the content height.
*/
private void updatePromoHeight() {
final float previousContentHeight = mContentHeightPx;
mContentHeightPx = getMeasuredHeight();
if (mIsVisible) {
// Calculates the ratio between the current height and the previous content height,
// and uses it to calculate the new height, while preserving the ratio.
final float ratio = mHeightPx / previousContentHeight;
mHeightPx = Math.round(mContentHeightPx * ratio);
}
}
}