chromium/chrome/browser/feed/android/java/src/org/chromium/chrome/browser/feed/FeedPlaceholderLayout.java

// Copyright 2020 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.feed;

import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.LayerDrawable;
import android.os.SystemClock;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.PathInterpolator;
import android.widget.ImageView;
import android.widget.LinearLayout;

import androidx.appcompat.widget.AppCompatImageView;

import org.chromium.base.CommandLine;
import org.chromium.base.Log;
import org.chromium.components.browser_ui.widget.displaystyle.UiConfig;

import java.util.ArrayList;
import java.util.List;

/** A {@link LinearLayout} that shows loading placeholder for Feed cards. */
public class FeedPlaceholderLayout extends LinearLayout {
    private static final String TAG = "FeedPlaceholder";

    /** Command line flag to allow rendering tests to disable animation. */
    public static final String DISABLE_ANIMATION_SWITCH = "disable-feed-placeholder-animation";

    private static final int CARD_MARGIN_DP = 12;
    private static final int CARD_TOP_PADDING_DP = 15;
    private static final int IMAGE_PLACEHOLDER_BOTTOM_PADDING_DP = 72;
    private static final int IMAGE_PLACEHOLDER_BOTTOM_PADDING_DENSE_DP = 48;
    private static final int IMAGE_PLACEHOLDER_SIZE_DP = 92;
    private static final int TEXT_CONTENT_HEIGHT_DP = 80;
    private static final int TEXT_PLACEHOLDER_HEIGHT_DP = 20;
    private static final int TEXT_PLACEHOLDER_RADIUS_DP = 12;
    private static final int LARGE_IMAGE_HEIGHT_DP = 207;

    private static final int START_DELAY_MS = 0;
    private static final int FADE_DURATION_MS = 620;
    private static final PathInterpolator INITIAL_FADE_IN_CURVE =
            new PathInterpolator(0.17f, 0.17f, 0.85f, 1f);
    private static final int FADE_STAGGER_MS = 83;
    private static final float HIGH_OPACITY = 1f;
    private static final float LOW_OPACITY = .6f;
    private static final PathInterpolator FADE_CYCLE_CURVE =
            new PathInterpolator(0.33f, 0f, 0.83f, 0.83f);

    private static final int MOVE_UP_DURATION_MS = 1283;
    private static final int MOVE_UP_DP = 33;
    private static final PathInterpolator MOVE_UP_CURVE =
            new PathInterpolator(0.17f, 0.17f, 0f, 1f);

    private final Context mContext;
    private final Resources mResources;
    private long mLayoutInflationCompleteMs;
    private int mScreenWidthDp;
    private boolean mIsFirstCardDense;
    private UiConfig mUiConfig;

    private final List<Animator> mFadeInAndMoveUpAnimators = new ArrayList<>();
    private final List<Animator> mFadeBounceAnimators = new ArrayList<>();
    private AnimatorSet mAllAnimations = new AnimatorSet();
    private final AnimatorSet mFadeInAndMoveUp = new AnimatorSet();
    private final AnimatorSet mFadeBounce = new AnimatorSet();

    public FeedPlaceholderLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        mContext = context;
        mResources = mContext.getResources();
        mScreenWidthDp = mResources.getConfiguration().screenWidthDp;
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mUiConfig = new UiConfig(this);
        setPlaceholders();
        mLayoutInflationCompleteMs = SystemClock.elapsedRealtime();
    }

    @Override
    public void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        mUiConfig.updateDisplayStyle();
    }

    @Override
    protected void onVisibilityChanged(View changedView, int visibility) {
        super.onVisibilityChanged(changedView, visibility);
        updateAnimationState(isAttachedToWindow());
    }

    @Override
    protected void onDetachedFromWindow() {
        // isAttachedToWindow() doesn't turn false during onDetachedFromWindow(), so we pass the new
        // attachment state into updateAnimationState() here explicitly.
        updateAnimationState(/* isAttached= */ false);
        super.onDetachedFromWindow();
    }

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        updateAnimationState(/* isAttached= */ true);
    }

    private void updateAnimationState(boolean isAttached) {
        // Some Android versions call onVisibilityChanged() during the View's constructor.
        if (mAllAnimations == null) return;

        boolean visible = isShown() && isAttached;
        if (mAllAnimations.isStarted() && !visible) {
            Log.d(TAG, "Canceling animation.");
            mAllAnimations.cancel();
        } else if (!mAllAnimations.isStarted() && visible) {
            Log.d(TAG, "Restarting animation.");
            mAllAnimations.start();
        }
    }

    /**
     * Set the header blank for the placeholder.The header blank should be consistent with the
     * sectionHeaderView of {@link ExploreSurfaceCoordinator.FeedSurfaceController#}
     */
    public void setBlankHeaderHeight(int headerHeight) {
        LinearLayout headerView = findViewById(R.id.feed_placeholder_header);
        ViewGroup.LayoutParams lp = headerView.getLayoutParams();
        lp.height = headerHeight;
        headerView.setLayoutParams(lp);
    }

    private void setPlaceholders() {
        LinearLayout cardsParentView = findViewById(R.id.placeholders_layout);
        cardsParentView.removeAllViews();

        LinearLayout.LayoutParams lp =
                new LinearLayout.LayoutParams(
                        ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        lp.bottomMargin = dpToPx(CARD_MARGIN_DP);

        // Set the First placeholder container - an image-right card. If it's in landscape mode, the
        // placeholder should always show in dense mode.
        mIsFirstCardDense =
                getResources().getConfiguration().orientation
                        == Configuration.ORIENTATION_LANDSCAPE;

        // The start delays of views' opacity animations are staggered. fadeStartDelayMs keeps track
        // of what the next view's opacity animation start delay should be.
        int fadeStartDelayMs = setPlaceholders(cardsParentView, true, lp, 0);

        // Set the second and the third placeholder containers - the large image on the top.
        fadeStartDelayMs = setPlaceholders(cardsParentView, false, lp, fadeStartDelayMs);
        setPlaceholders(cardsParentView, false, lp, fadeStartDelayMs);

        mFadeInAndMoveUp.setStartDelay(START_DELAY_MS);
        mFadeInAndMoveUp.playTogether(mFadeInAndMoveUpAnimators);
        mFadeBounce.playTogether(mFadeBounceAnimators);

        // Put animations in order.
        mAllAnimations.play(mFadeInAndMoveUp).before(mFadeBounce);
    }

    private int setPlaceholders(
            LinearLayout parent,
            boolean isSmallCard,
            ViewGroup.LayoutParams lp,
            int fadeStartDelayMs) {
        LinearLayout container = new LinearLayout(mContext);
        container.setLayoutParams(lp);
        container.setOrientation(isSmallCard ? HORIZONTAL : VERTICAL);
        ImageView imagePlaceholder = getImagePlaceholder(isSmallCard);
        ImageView textPlaceholder = getTextPlaceholder(isSmallCard);

        container.addView(
                isSmallCard
                        ? animate(textPlaceholder, fadeStartDelayMs)
                        : animate(imagePlaceholder, fadeStartDelayMs));
        fadeStartDelayMs += FADE_STAGGER_MS;
        container.addView(
                isSmallCard
                        ? animate(imagePlaceholder, fadeStartDelayMs)
                        : animate(textPlaceholder, fadeStartDelayMs));
        fadeStartDelayMs += FADE_STAGGER_MS;

        parent.addView(container);
        return fadeStartDelayMs;
    }

    private View animate(View view, int fadeStartDelayMs) {
        if (CommandLine.getInstance().hasSwitch(DISABLE_ANIMATION_SWITCH)) {
            return view;
        }

        // First, fade in from nothing.
        view.setAlpha(0f);
        view.setVisibility(View.VISIBLE);

        ObjectAnimator initialFadeIn = ObjectAnimator.ofFloat(view, "alpha", 0f, HIGH_OPACITY);
        initialFadeIn.setStartDelay(fadeStartDelayMs);
        initialFadeIn.setDuration(FADE_DURATION_MS);
        initialFadeIn.setInterpolator(INITIAL_FADE_IN_CURVE);
        mFadeInAndMoveUpAnimators.add(initialFadeIn);

        ObjectAnimator moveUp =
                ObjectAnimator.ofFloat(view, "translationY", dpToPx(MOVE_UP_DP), 0f);
        moveUp.setDuration(MOVE_UP_DURATION_MS);
        moveUp.setInterpolator(MOVE_UP_CURVE);
        mFadeInAndMoveUpAnimators.add(moveUp);

        ObjectAnimator pulse = ObjectAnimator.ofFloat(view, "alpha", HIGH_OPACITY, LOW_OPACITY);
        pulse.setStartDelay(fadeStartDelayMs);
        pulse.setDuration(FADE_DURATION_MS);
        pulse.setInterpolator(FADE_CYCLE_CURVE);
        pulse.setRepeatCount(ValueAnimator.INFINITE);
        pulse.setRepeatMode(ValueAnimator.REVERSE);
        mFadeBounceAnimators.add(pulse);

        return view;
    }

    private ImageView getImagePlaceholder(boolean isSmallCard) {
        LinearLayout.LayoutParams imagePlaceholderLp =
                new LinearLayout.LayoutParams(
                        ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        ImageView imagePlaceholder = new AppCompatImageView(mContext);
        imagePlaceholder.setImageDrawable(
                isSmallCard ? getSmallImageDrawable() : getLargeImageDrawable());
        imagePlaceholder.setLayoutParams(imagePlaceholderLp);
        imagePlaceholder.setScaleType(ImageView.ScaleType.FIT_XY);
        return imagePlaceholder;
    }

    private LayerDrawable getSmallImageDrawable() {
        int imageSize = dpToPx(IMAGE_PLACEHOLDER_SIZE_DP);
        int top = dpToPx(CARD_TOP_PADDING_DP);
        GradientDrawable[] placeholder = getRectangles(1, imageSize, imageSize);
        LayerDrawable layerDrawable = new LayerDrawable(placeholder);
        layerDrawable.setLayerInset(
                0,
                0,
                top,
                0,
                mIsFirstCardDense
                        ? dpToPx(IMAGE_PLACEHOLDER_BOTTOM_PADDING_DENSE_DP)
                        : dpToPx(IMAGE_PLACEHOLDER_BOTTOM_PADDING_DP));
        return layerDrawable;
    }

    private LayerDrawable getLargeImageDrawable() {
        GradientDrawable[] placeholder =
                getRectangles(1, dpToPx(mScreenWidthDp), dpToPx(LARGE_IMAGE_HEIGHT_DP));
        return new LayerDrawable(placeholder);
    }

    private ImageView getTextPlaceholder(boolean isSmallCard) {
        int top = dpToPx(CARD_TOP_PADDING_DP);
        int left = top / 2;
        int height = dpToPx(TEXT_PLACEHOLDER_HEIGHT_DP);
        int width = dpToPx(mScreenWidthDp);
        int contentHeight = dpToPx(TEXT_CONTENT_HEIGHT_DP);

        LinearLayout.LayoutParams textPlaceholderLp =
                isSmallCard
                        ? new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1)
                        : new LinearLayout.LayoutParams(
                                ViewGroup.LayoutParams.WRAP_CONTENT,
                                ViewGroup.LayoutParams.WRAP_CONTENT);

        LayerDrawable layerDrawable =
                isSmallCard
                        ? getSmallTextDrawable(top, width, height, contentHeight)
                        : getLargeTextDrawable(top, left, width, height, contentHeight + 2 * top);

        ImageView textPlaceholder = new AppCompatImageView(mContext);
        textPlaceholder.setImageDrawable(layerDrawable);
        textPlaceholder.setLayoutParams(textPlaceholderLp);
        textPlaceholder.setScaleType(ImageView.ScaleType.FIT_XY);
        return textPlaceholder;
    }

    private LayerDrawable getSmallTextDrawable(int top, int width, int height, int contentHeight) {
        GradientDrawable[] placeholders = getRectangles(4, width, height);
        int cardHeight =
                dpToPx(IMAGE_PLACEHOLDER_SIZE_DP)
                        + dpToPx(CARD_TOP_PADDING_DP)
                        + (mIsFirstCardDense
                                ? dpToPx(IMAGE_PLACEHOLDER_BOTTOM_PADDING_DENSE_DP)
                                : dpToPx(IMAGE_PLACEHOLDER_BOTTOM_PADDING_DP));
        LayerDrawable layerDrawable = new LayerDrawable(placeholders);
        // Title Placeholder
        layerDrawable.setLayerInset(0, 0, top, top, cardHeight - top - height);
        // Content Placeholder
        layerDrawable.setLayerInset(
                1,
                0,
                (contentHeight - height) / 2 + top,
                top,
                cardHeight - top - (height + contentHeight) / 2);
        layerDrawable.setLayerInset(
                2, 0, top + contentHeight - height, top, cardHeight - top - contentHeight);
        // Publisher Placeholder
        layerDrawable.setLayerInset(3, 0, cardHeight - top - height, top * 7, top);
        return layerDrawable;
    }

    private LayerDrawable getLargeTextDrawable(
            int top, int left, int width, int height, int contentHeight) {
        GradientDrawable[] placeholders = getRectangles(3, width, height);
        LayerDrawable layerDrawable = new LayerDrawable(placeholders);
        layerDrawable.setLayerInset(0, left, top, top, contentHeight - top - height);
        layerDrawable.setLayerInset(
                1, left, (contentHeight - height) / 2, top, (contentHeight - height) / 2);
        layerDrawable.setLayerInset(2, left, contentHeight - top - height, top, top);
        return layerDrawable;
    }

    private GradientDrawable[] getRectangles(int num, int width, int height) {
        GradientDrawable[] placeholders = new GradientDrawable[num];
        int radius = dpToPx(TEXT_PLACEHOLDER_RADIUS_DP);
        for (int i = 0; i < num; i++) {
            placeholders[i] = new GradientDrawable();
            placeholders[i].setShape(GradientDrawable.RECTANGLE);
            // The width here is not deterministic to what the rectangle looks like. It may be also
            // affected by layer inset left and right bound and the container padding.
            placeholders[i].setSize(width, height);
            placeholders[i].setCornerRadius(radius);
            placeholders[i].setColor(mContext.getColor(R.color.feed_placeholder_color));
        }
        return placeholders;
    }

    private int dpToPx(int dp) {
        return (int)
                TypedValue.applyDimension(
                        TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
    }

    public long getLayoutInflationCompleteMs() {
        return mLayoutInflationCompleteMs;
    }

    void setAnimatorSetForTesting(AnimatorSet animatorSet) {
        mAllAnimations = animatorSet;
    }
}