chromium/components/paint_preview/player/android/java/src/org/chromium/components/paintpreview/player/frame/PlayerFrameScrollController.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.components.paintpreview.player.frame;

import android.graphics.Rect;
import android.os.Handler;
import android.util.Size;
import android.widget.OverScroller;

import androidx.annotation.Nullable;

import org.chromium.components.paintpreview.player.OverscrollHandler;

/** Handles scrolling of a frame for the paint preview player. */
public class PlayerFrameScrollController {
    /** For swipe-to-refresh logic */
    private OverscrollHandler mOverscrollHandler;

    private boolean mIsOverscrolling;
    private float mOverscrollAmount;

    /** For computing flinging. */
    private final OverScroller mScroller;

    private final Handler mScrollerHandler = new Handler();

    /** References to shared state. */
    private final PlayerFrameViewport mViewport;

    private final Size mContentSize;

    /** Interface for calling shared methods on the mediator. */
    private final PlayerFrameMediatorDelegate mMediatorDelegate;

    private final Runnable mOnScrollListener;
    private final Runnable mOnFlingListener;
    private boolean mAcceptUserInput;
    private Runnable mOnScrollCallbackForAccessibility;

    PlayerFrameScrollController(
            OverScroller scroller,
            PlayerFrameMediatorDelegate mediatorDelegate,
            @Nullable Runnable onScrollListener,
            @Nullable Runnable onFlingListener) {
        mScroller = scroller;
        mViewport = mediatorDelegate.getViewport();
        mContentSize = mediatorDelegate.getContentSize();
        mMediatorDelegate = mediatorDelegate;
        mOnScrollListener = onScrollListener;
        mOnFlingListener = onFlingListener;
        mAcceptUserInput = true;
    }

    /** Sets the overscroll-to-refresh handler that handles pull-to-refresh behavior. */
    public void setOverscrollHandler(OverscrollHandler overscrollHandler) {
        mOverscrollHandler = overscrollHandler;
    }

    /**
     * Scrolls the viewport by a delta, but stays within {@link mContentSize}.
     * @param distanceX The delta on the x-axis.
     * @param distanceY The delta on the y-axis.
     * @return Whether the scrolling was possible and viewport was updated.
     */
    public boolean scrollBy(float distanceX, float distanceY) {
        mScroller.forceFinished(true);
        boolean result = scrollByInternal(distanceX, distanceY);
        if (result && mOnScrollListener != null) mOnScrollListener.run();
        return result;
    }

    /**
     * Handles flinging of the viewport.
     * @param velocityX The velocity in the x-direction.
     * @param velocityY The velocity in the y-direction.
     * @returns Whether the fling was consumed.
     */
    public boolean onFling(float velocityX, float velocityY) {
        if (!mAcceptUserInput) return false;

        final float scaleFactor = mViewport.getScale();
        int scaledContentWidth = (int) (mContentSize.getWidth() * scaleFactor);
        int scaledContentHeight = (int) (mContentSize.getHeight() * scaleFactor);
        mScroller.forceFinished(true);
        Rect viewportRect = mViewport.asRect();
        mScroller.fling(
                viewportRect.left,
                viewportRect.top,
                (int) -velocityX,
                (int) -velocityY,
                0,
                scaledContentWidth - viewportRect.width(),
                0,
                scaledContentHeight - viewportRect.height());

        if (!mScroller.isFinished() && mOnFlingListener != null) mOnFlingListener.run();
        mScrollerHandler.post(this::handleFling);
        return true;
    }

    /** Called when a touch event is released to possibly trigger overscroll-to-refresh. */
    public void onRelease() {
        if (mOverscrollHandler == null || !mIsOverscrolling) return;

        mOverscrollHandler.release();
        mIsOverscrolling = false;
        mOverscrollAmount = 0.0f;
    }

    /** Enables/disables processing input events for scrolling. */
    public void setAcceptUserInput(boolean acceptUserInput) {
        mAcceptUserInput = acceptUserInput;
    }

    /** Ensures that the given {@link Rect} is visible by scrolling the viewport to include it. */
    void scrollToMakeRectVisibleForAccessibility(Rect rect) {
        if (rect == null) return;

        float scaleFactor = mViewport.getScale();
        Rect targetRect =
                new Rect(
                        (int) (rect.left * scaleFactor),
                        (int) (rect.top * scaleFactor),
                        (int) (rect.right * scaleFactor),
                        (int) (rect.bottom * scaleFactor));
        Rect viewportRect = mViewport.asRect();

        if (viewportRect.contains(targetRect)) return;

        float scrollX;
        float scrollY;

        if (targetRect.top < viewportRect.top) {
            scrollY = targetRect.top - viewportRect.top;
        } else {
            scrollY = targetRect.top + targetRect.height() - viewportRect.bottom;
        }

        if (targetRect.left < viewportRect.left) {
            scrollX = targetRect.left - viewportRect.left;
        } else {
            scrollX = targetRect.left + targetRect.width() - viewportRect.right;
        }

        scrollBy(scrollX, scrollY);
    }

    void setOnScrollCallbackForAccessibility(Runnable onScrollCallback) {
        mOnScrollCallbackForAccessibility = onScrollCallback;
    }

    private boolean maybeHandleOverscroll(float distanceY) {
        if (mOverscrollHandler == null || mViewport.getTransY() >= 1f) return false;

        // Ignore if there is no active overscroll and the direction is down.
        if (!mIsOverscrolling && distanceY <= 0) return false;

        // TODO(crbug.com/40137904): Propagate this state to child mediators to
        // support easing.
        mOverscrollAmount += distanceY;

        // If the overscroll is completely eased off the cancel the event.
        if (mOverscrollAmount <= 0) {
            mIsOverscrolling = false;
            mOverscrollHandler.reset();
            return false;
        }

        // Start the overscroll event if the scroll direction is correct and one isn't active.
        if (!mIsOverscrolling && distanceY > 0) {
            mOverscrollAmount = distanceY;
            mIsOverscrolling = mOverscrollHandler.start();
        }
        mOverscrollHandler.pull(distanceY);
        return mIsOverscrolling;
    }

    private boolean scrollByInternal(float distanceX, float distanceY) {
        if (!mAcceptUserInput) return false;

        if (maybeHandleOverscroll(-distanceY)) return true;

        int validDistanceX = 0;
        int validDistanceY = 0;
        final float scaleFactor = mViewport.getScale();
        float scaledContentWidth = mContentSize.getWidth() * scaleFactor;
        float scaledContentHeight = mContentSize.getHeight() * scaleFactor;

        Rect viewportRect = mViewport.asRect();
        if (viewportRect.left > 0 && distanceX < 0) {
            validDistanceX = (int) Math.max(distanceX, -1f * viewportRect.left);
        } else if (viewportRect.right < scaledContentWidth && distanceX > 0) {
            validDistanceX = (int) Math.min(distanceX, scaledContentWidth - viewportRect.right);
        }
        if (viewportRect.top > 0 && distanceY < 0) {
            validDistanceY = (int) Math.max(distanceY, -1f * viewportRect.top);
        } else if (viewportRect.bottom < scaledContentHeight && distanceY > 0) {
            validDistanceY = (int) Math.min(distanceY, scaledContentHeight - viewportRect.bottom);
        }

        if (validDistanceX == 0 && validDistanceY == 0) {
            return false;
        }

        mMediatorDelegate.offsetBitmapScaleMatrix(validDistanceX, validDistanceY);
        mViewport.offset(validDistanceX, validDistanceY);
        mMediatorDelegate.updateVisuals(false);
        if (mOnScrollCallbackForAccessibility != null) mOnScrollCallbackForAccessibility.run();
        return true;
    }

    /** Handles a fling update by computing the next scroll offset and programmatically scrolling. */
    private void handleFling() {
        if (mScroller.isFinished()) return;

        boolean shouldContinue = mScroller.computeScrollOffset();
        int deltaX = mScroller.getCurrX() - Math.round(mViewport.getTransX());
        int deltaY = mScroller.getCurrY() - Math.round(mViewport.getTransY());
        scrollByInternal(deltaX, deltaY);

        if (shouldContinue) {
            mScrollerHandler.post(this::handleFling);
        }
    }
}