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

// Copyright 2021 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.annotation.SuppressLint;
import android.app.Activity;
import android.util.DisplayMetrics;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.MeasureSpec;
import android.view.ViewConfiguration;
import android.view.ViewGroup;

import androidx.annotation.IdRes;
import androidx.annotation.NonNull;

import org.chromium.base.ObserverList;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.browser.user_education.IPHCommandBuilder;
import org.chromium.chrome.browser.user_education.UserEducationHelper;
import org.chromium.components.browser_ui.styles.ChromeColors;
import org.chromium.components.browser_ui.styles.SemanticColorUtils;
import org.chromium.components.feature_engagement.FeatureConstants;
import org.chromium.third_party.android.swiperefresh.CircleImageView;
import org.chromium.third_party.android.swiperefresh.SwipeRefreshLayout;

/**
 * Makes the modified version of SwipeRefreshLayout support layout, measuring and touch handling
 * of direct child.
 */
public class FeedSwipeRefreshLayout extends SwipeRefreshLayout implements ScrollListener {
    public static final int IPH_WAIT_TIME_MS = 5 * 1000;
    // Offset in dips from the top of the view to where the progress spinner should start.
    private static final int SPINNER_START_OFFSET = 16;
    // Offset in dips from the top of the view to where the progress spinner should stop.
    private static final int SPINNER_END_OFFSET = 80;
    // Offset in dips from the bottom of the view to where the progress spinner should be shown when
    // switched to a "bottom" spinner (non-pull refresh).
    private static final int SPINNER_OFFSET_FROM_BOTTOM = 100;

    private final Activity mActivity;
    @IdRes private final int mAnchorViewId;
    private View mTarget; // the target of the gesture.
    private final int mTouchSlop;
    private final ObserverList<SwipeRefreshLayout.OnRefreshListener> mRefreshListeners =
            new ObserverList<>();
    private float mLastMotionY;
    private boolean mIsBeingDragged;
    private ScrollableContainerDelegate mScrollableContainerDelegate;
    private int mHeaderOffset;

    /**
     * Creates and returns an instance of {@link FeedSwipeRefreshLayout}.
     * @param activity The current {@link Activity}.
     * @param anchorViewId ID of the view below which this layout is anchored.
     */
    public static FeedSwipeRefreshLayout create(
            @NonNull Activity activity, @IdRes int anchorViewId) {
        FeedSwipeRefreshLayout instance = new FeedSwipeRefreshLayout(activity, anchorViewId);
        instance.setLayoutParams(
                new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
        instance.setProgressBackgroundColorSchemeColor(
                ChromeColors.getSurfaceColor(activity, R.dimen.default_elevation_2));
        instance.setColorSchemeColors(SemanticColorUtils.getDefaultControlColorActive(activity));
        instance.setEnabled(false);
        final DisplayMetrics metrics = activity.getResources().getDisplayMetrics();
        instance.setProgressViewOffset(
                false,
                (int) (SPINNER_START_OFFSET * metrics.density),
                (int) (SPINNER_END_OFFSET * metrics.density));
        instance.addOnRefreshListener(
                new SwipeRefreshLayout.OnRefreshListener() {
                    @Override
                    public void onRefresh() {
                        instance.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_ASSERTIVE);
                        instance.setContentDescription(
                                activity.getResources()
                                        .getString(R.string.accessibility_swipe_refresh));
                        RecordUserAction.record("MobilePullGestureReloadNTP");
                    }
                });
        return instance;
    }

    private FeedSwipeRefreshLayout(@NonNull Activity activity, @IdRes int anchorViewId) {
        super(activity);
        mActivity = activity;
        mAnchorViewId = anchorViewId;
        mTouchSlop = ViewConfiguration.get(activity).getScaledTouchSlop();

        setOnRefreshListener(
                new OnRefreshListener() {
                    @Override
                    public void onRefresh() {
                        for (OnRefreshListener listener : mRefreshListeners) {
                            listener.onRefresh();
                        }
                    }
                });
    }

    /** Shows an IPH. */
    public void showIPH(UserEducationHelper helper) {
        ViewGroup contentContainer = mActivity.findViewById(android.R.id.content);
        if (contentContainer == null) return;
        // Only toolbar_container view appears in both NTP and start surface.
        View toolbarView = contentContainer.findViewById(mAnchorViewId);
        if (toolbarView == null) return;
        helper.requestShowIPH(
                new IPHCommandBuilder(
                                getContext().getResources(),
                                FeatureConstants.FEED_SWIPE_REFRESH_FEATURE,
                                R.string.feed_swipe_refresh_iph,
                                R.string.accessibility_feed_swipe_refresh_iph)
                        .setAnchorView(toolbarView)
                        .setDismissOnTouch(true)
                        .setAutoDismissTimeout(IPH_WAIT_TIME_MS)
                        .build());
    }

    /**
     * Enables the swipe gesture.
     * @param scrollableContainerDelegate Delegate for the scrollable container.
     */
    public void enableSwipe(ScrollableContainerDelegate scrollableContainerDelegate) {
        if (isEnabled()) return;
        setEnabled(true);

        if (scrollableContainerDelegate != null) {
            mScrollableContainerDelegate = scrollableContainerDelegate;
            mScrollableContainerDelegate.addScrollListener(this);
        }
    }

    /** Disables the swipe gesture. */
    public void disableSwipe() {
        if (!isEnabled()) return;
        setEnabled(false);

        if (mScrollableContainerDelegate != null) {
            mScrollableContainerDelegate.removeScrollListener(this);
            mScrollableContainerDelegate = null;
        }
    }

    /**
     * Adds the listener to be notified when a refresh is triggered via the swipe gesture.
     * @param listener Listener to add.
     */
    public void addOnRefreshListener(SwipeRefreshLayout.OnRefreshListener listener) {
        mRefreshListeners.addObserver(listener);
    }

    /**
     * Removes the listener to be notified when a refresh is triggered via the swipe gesture.
     * @param listener Listener to remove.
     */
    public void removeOnRefreshListener(SwipeRefreshLayout.OnRefreshListener listener) {
        mRefreshListeners.removeObserver(listener);
    }

    /**
     * Starts a refreshing spinner at the bottom of the view. Should only be used for non-swipe
     * refreshes.
     */
    public void startRefreshingAtTheBottom() {
        final DisplayMetrics metrics = mActivity.getResources().getDisplayMetrics();
        // The offset will limited to show the spiiner as high as the vertical middle of the view.
        int offset =
                Math.max(
                        metrics.heightPixels / 2,
                        metrics.heightPixels
                                - ((int) (SPINNER_OFFSET_FROM_BOTTOM * metrics.density)));
        setProgressViewEndTarget(false, offset);
        setRefreshing(true);
        setProgressViewEndTarget(false, (int) (SPINNER_END_OFFSET * metrics.density));
    }

    private void ensureTarget() {
        if (mTarget == null) {
            for (int i = 0; i < getChildCount(); i++) {
                View child = getChildAt(i);
                if (!(child instanceof CircleImageView)) {
                    mTarget = child;
                    break;
                }
            }
        }
    }

    @Override
    public void onWindowVisibilityChanged(int visibility) {
        super.onWindowVisibilityChanged(visibility);
        // If the view is gone, i.e. switching to tab switcher mode, reset any effect that is still
        // in progress.
        if (visibility == View.GONE) {
            reset();
        }
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        ensureTarget();
        if (mTarget == null) {
            return;
        }
        final int width = getMeasuredWidth();
        final int height = getMeasuredHeight();
        final View child = mTarget;
        final int childLeft = getPaddingLeft();
        final int childTop = getPaddingTop();
        final int childWidth = width - getPaddingLeft() - getPaddingRight();
        final int childHeight = height - getPaddingTop() - getPaddingBottom();
        child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight);
    }

    @Override
    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        ensureTarget();
        if (mTarget == null) {
            return;
        }
        mTarget.measure(
                MeasureSpec.makeMeasureSpec(
                        getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
                        MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec(
                        getMeasuredHeight() - getPaddingTop() - getPaddingBottom(),
                        MeasureSpec.EXACTLY));
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        if (!isEnabled()) return false;
        if (mScrollableContainerDelegate != null) {
            // For start surface, the target view is task view which doesn't move. We need to rely
            // on onHeaderOffsetChanged event that is fired with header offset.
            if (mHeaderOffset != 0) return false;
        } else {
            // Otherwise for pure New Tab Page, the target view is RecyclerView and we can check it
            // directly to determine if it can scroll up.
            ensureTarget();
            if (mTarget == null || mTarget.canScrollVertically(-1)) return false;
        }

        final int action = event.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                {
                    mIsBeingDragged = false;
                    final float y = event.getY();
                    if (y == -1) {
                        return false;
                    }
                    mLastMotionY = y;
                    break;
                }

            case MotionEvent.ACTION_MOVE:
                {
                    final float y = event.getY();
                    if (y == -1) {
                        return false;
                    }
                    final float yDiff = y - mLastMotionY;
                    if (yDiff > mTouchSlop && !mIsBeingDragged) {
                        mIsBeingDragged = true;
                        start();
                    }
                    break;
                }

            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                mIsBeingDragged = false;
                break;
        }

        return mIsBeingDragged;
    }

    @Override
    @SuppressLint("ClickableViewAccessibility")
    public boolean onTouchEvent(MotionEvent event) {
        if (!isEnabled()) return false;
        final int action = event.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mIsBeingDragged = false;
                break;

            case MotionEvent.ACTION_MOVE:
                pull(event.getY() - mLastMotionY);
                mLastMotionY = event.getY();
                break;

            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                release(true);
                mIsBeingDragged = false;
                return false;
        }

        return true;
    }

    @Override
    public void onScrollStateChanged(@ScrollState int state) {}

    @Override
    public void onScrolled(int dx, int dy) {}

    @Override
    public void onHeaderOffsetChanged(int headerOffset) {
        mHeaderOffset = headerOffset;
    }
}