chromium/chrome/android/java/src/org/chromium/chrome/browser/ntp/SnapScrollHelperImpl.java

// Copyright 2018 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.ntp;

import android.annotation.SuppressLint;
import android.content.res.Resources;
import android.view.MotionEvent;
import android.view.View;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;

import org.chromium.chrome.R;
import org.chromium.chrome.browser.feed.SnapScrollHelper;

/** This class handles snap scroll for the search box on a {@link NewTabPage}. */
public class SnapScrollHelperImpl implements SnapScrollHelper {
    private static final long SNAP_SCROLL_DELAY_MS = 30;

    private final NewTabPageManager mManager;
    private final NewTabPageLayout mNewTabPageLayout;
    private final Runnable mSnapScrollRunnable;
    private final Runnable mUpdateSearchBoxOnScrollRunnable;
    private final int mToolbarHeight;
    private final int mSearchBoxTransitionStartOffset;
    private final int mSearchBoxTransitionEndOffset;

    private View mView;
    private boolean mPendingSnapScroll;
    private int mLastScrollY = -1;

    /**
     * @param manager The {@link NewTabPageManager} to get information about user interactions on
     *                the {@link NewTabPage}.
     * @param newTabPageLayout The {@link NewTabPageLayout} associated with the {@link NewTabPage}.
     */
    public SnapScrollHelperImpl(NewTabPageManager manager, NewTabPageLayout newTabPageLayout) {
        mManager = manager;
        mNewTabPageLayout = newTabPageLayout;
        mSnapScrollRunnable = new SnapScrollRunnable();
        mUpdateSearchBoxOnScrollRunnable = mNewTabPageLayout::updateSearchBoxOnScroll;

        Resources res = newTabPageLayout.getResources();
        mToolbarHeight =
                res.getDimensionPixelSize(R.dimen.toolbar_height_no_shadow)
                        + res.getDimensionPixelSize(R.dimen.toolbar_progress_bar_height);
        mSearchBoxTransitionStartOffset =
                res.getDimensionPixelSize(R.dimen.ntp_search_box_transition_start_offset);
        mSearchBoxTransitionEndOffset =
                res.getDimensionPixelSize(R.dimen.ntp_search_box_transition_end_offset);
    }

    /**
     * @param view The view on which this class needs to handle snap scroll.
     */
    @Override
    public void setView(@NonNull View view) {
        if (mView != null) {
            mPendingSnapScroll = false;
            mLastScrollY = -1;
            mView.removeCallbacks(mSnapScrollRunnable);
            mView.setOnTouchListener(null);
        }

        mView = view;

        @SuppressLint("ClickableViewAccessibility")
        View.OnTouchListener onTouchListener =
                (v, event) -> {
                    mView.removeCallbacks(mSnapScrollRunnable);

                    if (event.getActionMasked() == MotionEvent.ACTION_CANCEL
                            || event.getActionMasked() == MotionEvent.ACTION_UP) {
                        mPendingSnapScroll = true;
                        mView.postDelayed(mSnapScrollRunnable, SNAP_SCROLL_DELAY_MS);
                    } else {
                        mPendingSnapScroll = false;
                    }
                    return false;
                };
        mView.setOnTouchListener(onTouchListener);
    }

    /** Update scroll offset and perform snap scroll if necessary. */
    @Override
    public void handleScroll() {
        int scrollY = mNewTabPageLayout.getScrollDelegate().getVerticalScrollOffset();
        if (mLastScrollY == scrollY) return;

        mLastScrollY = scrollY;
        if (mPendingSnapScroll) {
            mView.removeCallbacks(mSnapScrollRunnable);
            mView.postDelayed(mSnapScrollRunnable, SNAP_SCROLL_DELAY_MS);
        }
        mNewTabPageLayout.updateSearchBoxOnScroll();
    }

    /**
     * Resets any pending callbacks to update the search box position, and add new callback to
     * update the search box position if necessary. This is used whenever {@link #handleScroll()} is
     * not reliable (e.g. when an item is dismissed, the items at the top of the viewport might not
     * move, and onScrolled() might not be called).
     * @param update Whether a new callback to update search box should be posted to {@link #mView}.
     */
    @Override
    public void resetSearchBoxOnScroll(boolean update) {
        mView.removeCallbacks(mUpdateSearchBoxOnScrollRunnable);
        if (update) mView.post(mUpdateSearchBoxOnScrollRunnable);
    }

    /**
     * @param scrollPosition The scroll position that the snap scroll calculation is based on.
     * @return The modified scroll position that accounts for snap scroll.
     */
    @VisibleForTesting
    @Override
    public int calculateSnapPosition(int scrollPosition) {
        if (mManager.isLocationBarShownInNtp()) {
            // Snap scroll to prevent only part of the toolbar from showing.
            scrollPosition = calculateSnapPositionForRegion(scrollPosition, 0, mToolbarHeight);

            // Snap scroll to prevent resting in the middle of the omnibox transition.
            View fakeBox = mNewTabPageLayout.getSearchBoxView();
            int fakeBoxUpperBound = fakeBox.getTop() + fakeBox.getPaddingTop();
            scrollPosition =
                    calculateSnapPositionForRegion(
                            scrollPosition,
                            fakeBoxUpperBound - mSearchBoxTransitionStartOffset,
                            fakeBoxUpperBound + mSearchBoxTransitionEndOffset);
        }

        return scrollPosition;
    }

    /**
     * Calculates the position to scroll to in order to move out of a region where {@code mView}
     * should not stay at rest.
     * @param currentScroll the current scroll position.
     * @param regionStart the beginning of the region to scroll out of.
     * @param regionEnd the end of the region to scroll out of.
     * @param flipPoint the threshold used to decide which bound of the region to scroll to.
     * @return the position to scroll to.
     */
    private static int calculateSnapPositionForRegion(
            int currentScroll, int regionStart, int regionEnd, int flipPoint) {
        assert regionStart <= flipPoint;
        assert flipPoint <= regionEnd;

        if (currentScroll < regionStart || currentScroll > regionEnd) return currentScroll;

        if (currentScroll < flipPoint) {
            return regionStart;
        } else {
            return regionEnd;
        }
    }

    /**
     * If {@code mView} is currently scrolled to between regionStart and regionEnd, smooth scroll
     * out of the region to the nearest edge.
     */
    private static int calculateSnapPositionForRegion(
            int currentScroll, int regionStart, int regionEnd) {
        return calculateSnapPositionForRegion(
                currentScroll, regionStart, regionEnd, (regionStart + regionEnd) / 2);
    }

    private class SnapScrollRunnable implements Runnable {
        @Override
        public void run() {
            assert mPendingSnapScroll;
            mPendingSnapScroll = false;

            mNewTabPageLayout.getScrollDelegate().snapScroll();
        }
    }
}