Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 60eec1c7 authored by Mark Renouf's avatar Mark Renouf Committed by Android (Google) Code Review
Browse files

Merge "Adds scroll capture support for RecyclerView"

parents f3399316 7d8968bc
Loading
Loading
Loading
Loading
+202 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.internal.view;

import android.animation.ValueAnimator;
import android.annotation.NonNull;
import android.graphics.Rect;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;

/**
 * ScrollCapture for RecyclerView and <i>RecyclerView-like</i> ViewGroups.
 * <p>
 * Requirements for proper operation:
 * <ul>
 * <li>at least one visible child view</li>
 * <li>scrolls by pixels in response to {@link View#scrollBy(int, int)}.
 * <li>reports ability to scroll with {@link View#canScrollVertically(int)}
 * <li>properly implements {@link ViewParent#requestChildRectangleOnScreen(View, Rect, boolean)}
 * </ul>
 *
 * @see ScrollCaptureViewSupport
 */
public class RecyclerViewCaptureHelper implements ScrollCaptureViewHelper<ViewGroup> {

    // Experiment
    private static final boolean DISABLE_ANIMATORS = false;
    // Experiment
    private static final boolean STOP_RENDER_THREAD = false;

    private static final String TAG = "RVCaptureHelper";
    private int mScrollDelta;
    private boolean mScrollBarWasEnabled;
    private int mOverScrollMode;
    private float mDurationScale;

    @Override
    public void onPrepareForStart(@NonNull ViewGroup view, Rect scrollBounds) {
        mScrollDelta = 0;

        mOverScrollMode = view.getOverScrollMode();
        view.setOverScrollMode(View.OVER_SCROLL_NEVER);

        mScrollBarWasEnabled = view.isVerticalScrollBarEnabled();
        view.setVerticalScrollBarEnabled(false);
        if (DISABLE_ANIMATORS) {
            mDurationScale = ValueAnimator.getDurationScale();
            ValueAnimator.setDurationScale(0);
        }
        if (STOP_RENDER_THREAD) {
            view.getThreadedRenderer().stop();
        }
    }

    @Override
    public ScrollResult onScrollRequested(@NonNull ViewGroup recyclerView, Rect scrollBounds,
            Rect requestRect) {
        ScrollResult result = new ScrollResult();
        result.requestedArea = new Rect(requestRect);
        result.scrollDelta = mScrollDelta;
        result.availableArea = new Rect(); // empty

        Log.d(TAG, "current scrollDelta: " + mScrollDelta);
        if (!recyclerView.isVisibleToUser() || recyclerView.getChildCount() == 0) {
            Log.w(TAG, "recyclerView is empty or not visible, cannot continue");
            return result; // result.availableArea == empty Rect
        }

        // move from scrollBounds-relative to parent-local coordinates
        Rect requestedContainerBounds = new Rect(requestRect);
        requestedContainerBounds.offset(0, -mScrollDelta);
        requestedContainerBounds.offset(scrollBounds.left, scrollBounds.top);

        // requestedContainerBounds is now in recyclerview-local coordinates
        Log.d(TAG, "requestedContainerBounds: " + requestedContainerBounds);

        // Save a copy for later
        View anchor = findChildNearestTarget(recyclerView, requestedContainerBounds);
        if (anchor == null) {
            Log.d(TAG, "Failed to locate anchor view");
            return result; // result.availableArea == null
        }

        Log.d(TAG, "Anchor view:" + anchor);
        Rect requestedContentBounds = new Rect(requestedContainerBounds);
        recyclerView.offsetRectIntoDescendantCoords(anchor, requestedContentBounds);

        Log.d(TAG, "requestedContentBounds = " + requestedContentBounds);
        int prevAnchorTop = anchor.getTop();
        // Note: requestChildRectangleOnScreen may modify rectangle, must pass pass in a copy here
        Rect input = new Rect(requestedContentBounds);
        if (recyclerView.requestChildRectangleOnScreen(anchor, input, true)) {
            int scrolled = prevAnchorTop - anchor.getTop(); // inverse of movement
            Log.d(TAG, "RecyclerView scrolled by " + scrolled + " px");
            mScrollDelta += scrolled; // view.top-- is equivalent to parent.scrollY++
            result.scrollDelta = mScrollDelta;
            Log.d(TAG, "requestedContentBounds, (post-request-rect) = " + requestedContentBounds);
        }

        requestedContainerBounds.set(requestedContentBounds);
        recyclerView.offsetDescendantRectToMyCoords(anchor, requestedContainerBounds);
        Log.d(TAG, "requestedContainerBounds, (post-scroll): " + requestedContainerBounds);

        Rect recyclerLocalVisible = new Rect(scrollBounds);
        recyclerView.getLocalVisibleRect(recyclerLocalVisible);
        Log.d(TAG, "recyclerLocalVisible: " + recyclerLocalVisible);

        if (!requestedContainerBounds.intersect(recyclerLocalVisible)) {
            // Requested area is still not visible
            Log.d(TAG, "requested bounds not visible!");
            return result;
        }
        Rect available = new Rect(requestedContainerBounds);
        available.offset(-scrollBounds.left, -scrollBounds.top);
        available.offset(0, mScrollDelta);
        result.availableArea = available;
        Log.d(TAG, "availableArea: " + result.availableArea);
        return result;
    }

    /**
     * Find a view that is located "closest" to targetRect. Returns the first view to fully
     * vertically overlap the target targetRect. If none found, returns the view with an edge
     * nearest the target targetRect.
     *
     * @param parent the parent vertical layout
     * @param targetRect a rectangle in local coordinates of <code>parent</code>
     * @return a child view within parent matching the criteria or null
     */
    static View findChildNearestTarget(ViewGroup parent, Rect targetRect) {
        View selected = null;
        int minCenterDistance = Integer.MAX_VALUE;
        int maxOverlap = 0;

        // allowable center-center distance, relative to targetRect.
        // if within this range, taller views are preferred
        final float preferredRangeFromCenterPercent = 0.25f;
        final int preferredDistance =
                (int) (preferredRangeFromCenterPercent * targetRect.height());

        Rect parentLocalVis = new Rect();
        parent.getLocalVisibleRect(parentLocalVis);
        Log.d(TAG, "findChildNearestTarget: parentVis=" + parentLocalVis
                + " targetRect=" + targetRect);

        Rect frame = new Rect();
        for (int i = 0; i < parent.getChildCount(); i++) {
            final View child = parent.getChildAt(i);
            child.getHitRect(frame);
            Log.d(TAG, "child #" + i + " hitRect=" + frame);

            if (child.getVisibility() != View.VISIBLE) {
                Log.d(TAG, "child #" + i + " is not visible");
                continue;
            }

            int centerDistance = Math.abs(targetRect.centerY() - frame.centerY());
            Log.d(TAG, "child #" + i + " : center to center: " + centerDistance + "px");

            if (centerDistance < minCenterDistance) {
                // closer to center
                minCenterDistance = centerDistance;
                selected = child;
            } else if (frame.intersect(targetRect) && (frame.height() > preferredDistance)) {
                // within X% pixels of center, but taller
                selected = child;
            }
        }
        return selected;
    }


    @Override
    public void onPrepareForEnd(@NonNull ViewGroup view) {
        // Restore original position and state
        view.scrollBy(0, mScrollDelta);
        view.setOverScrollMode(mOverScrollMode);
        view.setVerticalScrollBarEnabled(mScrollBarWasEnabled);
        if (DISABLE_ANIMATORS) {
            ValueAnimator.setDurationScale(mDurationScale);
        }
        if (STOP_RENDER_THREAD) {
            view.getThreadedRenderer().start();
        }
    }
}
+88 −3
Original line number Diff line number Diff line
@@ -17,8 +17,11 @@
package com.android.internal.view;

import android.annotation.Nullable;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Point;
import android.graphics.Rect;
import android.util.Log;
import android.view.ScrollCaptureCallback;
import android.view.View;
import android.view.ViewGroup;
@@ -29,6 +32,12 @@ import android.view.ViewGroup;
public class ScrollCaptureInternal {
    private static final String TAG = "ScrollCaptureInternal";

    // Log found scrolling views
    private static final boolean DEBUG = true;

    // Log all investigated views, as well as heuristic checks
    private static final boolean DEBUG_VERBOSE = false;

    private static final int UP = -1;
    private static final int DOWN = 1;

@@ -57,38 +66,72 @@ public class ScrollCaptureInternal {
     * This needs to be fast and not alloc memory. It's called on everything in the tree not marked
     * as excluded during scroll capture search.
     */
    public static int detectScrollingType(View view) {
    private static int detectScrollingType(View view) {
        // Must be a ViewGroup
        if (!(view instanceof ViewGroup)) {
            if (DEBUG_VERBOSE) {
                Log.v(TAG, "hint: not a subclass of ViewGroup");
            }
            return TYPE_FIXED;
        }
        if (DEBUG_VERBOSE) {
            Log.v(TAG, "hint: is a subclass of ViewGroup");
        }
        // Confirm that it can scroll.
        if (!(view.canScrollVertically(DOWN) || view.canScrollVertically(UP))) {
            // Nothing to scroll here, move along.
            if (DEBUG_VERBOSE) {
                Log.v(TAG, "hint: cannot be scrolled");
            }
            return TYPE_FIXED;
        }
        if (DEBUG_VERBOSE) {
            Log.v(TAG, "hint: can be scrolled up or down");
        }
        // ScrollViews accept only a single child.
        if (((ViewGroup) view).getChildCount() > 1) {
            if (DEBUG_VERBOSE) {
                Log.v(TAG, "hint: scrollable with multiple children");
            }
            return TYPE_RECYCLING;
        }
        if (DEBUG_VERBOSE) {
            Log.v(TAG, "hint: less than two child views");
        }
        //Because recycling containers don't use scrollY, a non-zero value means Scroll view.
        if (view.getScrollY() != 0) {
            if (DEBUG_VERBOSE) {
                Log.v(TAG, "hint: scrollY != 0");
            }
            return TYPE_SCROLLING;
        }
        Log.v(TAG, "hint: scrollY == 0");
        // Since scrollY cannot be negative, this means a Recycling view.
        if (view.canScrollVertically(UP)) {
            if (DEBUG_VERBOSE) {
                Log.v(TAG, "hint: able to scroll up");
            }
            return TYPE_RECYCLING;
        }
        // canScrollVertically(UP) == false, getScrollY() == 0, getChildCount() == 1.
        if (DEBUG_VERBOSE) {
            Log.v(TAG, "hint: cannot be scrolled up");
        }

        // canScrollVertically(UP) == false, getScrollY() == 0, getChildCount() == 1.
        // For Recycling containers, this should be a no-op (RecyclerView logs a warning)
        view.scrollTo(view.getScrollX(), 1);

        // A scrolling container would have moved by 1px.
        if (view.getScrollY() == 1) {
            view.scrollTo(view.getScrollX(), 0);
            if (DEBUG_VERBOSE) {
                Log.v(TAG, "hint: scrollTo caused scrollY to change");
            }
            return TYPE_SCROLLING;
        }
        if (DEBUG_VERBOSE) {
            Log.v(TAG, "hint: scrollTo did not cause scrollY to change");
        }
        return TYPE_RECYCLING;
    }

@@ -99,19 +142,61 @@ public class ScrollCaptureInternal {
     * @param localVisibleRect the visible area of the given view in local coordinates, as supplied
     *                         by the view parent
     * @param positionInWindow the offset of localVisibleRect within the window
     *
     * @return a new callback or null if the View isn't supported
     */
    @Nullable
    public ScrollCaptureCallback requestCallback(View view, Rect localVisibleRect,
            Point positionInWindow) {
        // Nothing to see here yet.
        if (DEBUG_VERBOSE) {
            Log.v(TAG, "scroll capture: checking " + view.getClass().getName()
                    + "[" + resolveId(view.getContext(), view.getId()) + "]");
        }
        int i = detectScrollingType(view);
        switch (i) {
            case TYPE_SCROLLING:
                if (DEBUG) {
                    Log.d(TAG, "scroll capture: FOUND " + view.getClass().getName()
                            + "[" + resolveId(view.getContext(), view.getId()) + "]"
                            + " -> TYPE_SCROLLING");
                }
                return new ScrollCaptureViewSupport<>((ViewGroup) view,
                        new ScrollViewCaptureHelper());
            case TYPE_RECYCLING:
                if (DEBUG) {
                    Log.d(TAG, "scroll capture: FOUND " + view.getClass().getName()
                            + "[" + resolveId(view.getContext(), view.getId()) + "]"
                            + " -> TYPE_RECYCLING");
                }
                return new ScrollCaptureViewSupport<>((ViewGroup) view,
                        new RecyclerViewCaptureHelper());
            case TYPE_FIXED:
                // ignore
                break;

        }
        return null;
    }

    // Lifted from ViewDebug (package protected)

    private static String formatIntToHexString(int value) {
        return "0x" + Integer.toHexString(value).toUpperCase();
    }

    static String resolveId(Context context, int id) {
        String fieldValue;
        final Resources resources = context.getResources();
        if (id >= 0) {
            try {
                fieldValue = resources.getResourceTypeName(id) + '/'
                        + resources.getResourceEntryName(id);
            } catch (Resources.NotFoundException e) {
                fieldValue = "id/" + formatIntToHexString(id);
            }
        } else {
            fieldValue = "NO_ID";
        }
        return fieldValue;
    }
}
+7 −6
Original line number Diff line number Diff line
@@ -17,7 +17,6 @@
package com.android.internal.view;

import android.annotation.NonNull;
import android.annotation.Nullable;
import android.graphics.Rect;
import android.view.View;

@@ -62,8 +61,8 @@ interface ScrollCaptureViewHelper<V extends View> {
     * @param view the view being captured
     * @return true if the callback should respond to a request with scroll bounds
     */
    default boolean onAcceptSession(@Nullable V view) {
        return view != null && view.isVisibleToUser()
    default boolean onAcceptSession(@NonNull V view) {
        return view.isVisibleToUser()
                && (view.canScrollVertically(UP) || view.canScrollVertically(DOWN));
    }

@@ -73,7 +72,7 @@ interface ScrollCaptureViewHelper<V extends View> {
     *
     * @param view the view being captured
     */
    default Rect onComputeScrollBounds(@Nullable V view) {
    @NonNull default Rect onComputeScrollBounds(@NonNull V view) {
        return new Rect(view.getPaddingLeft(), view.getPaddingTop(),
                view.getWidth() - view.getPaddingRight(),
                view.getHeight() - view.getPaddingBottom());
@@ -88,7 +87,7 @@ interface ScrollCaptureViewHelper<V extends View> {
     * @param view         the view being captured
     * @param scrollBounds the bounds within {@code view} where content scrolls
     */
    void onPrepareForStart(@NonNull V view, Rect scrollBounds);
    void onPrepareForStart(@NonNull V view, @NonNull Rect scrollBounds);

    /**
     * Map the request onto the screen.
@@ -105,7 +104,9 @@ interface ScrollCaptureViewHelper<V extends View> {
     *                     content to capture for the request
     * @return the result of the request as a {@link ScrollResult}
     */
    ScrollResult onScrollRequested(@NonNull V view, Rect scrollBounds, Rect requestRect);
    @NonNull
    ScrollResult onScrollRequested(@NonNull V view, @NonNull Rect scrollBounds,
            @NonNull Rect requestRect);

    /**
     * Restore the target after capture.
+53 −19
Original line number Diff line number Diff line
@@ -23,8 +23,8 @@ import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.RenderNode;
import android.os.Handler;
import android.os.SystemClock;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.ScrollCaptureCallback;
import android.view.ScrollCaptureSession;
import android.view.Surface;
@@ -46,8 +46,12 @@ import java.util.function.Consumer;
 */
public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCallback {

    public static final long NO_FRAME_PRODUCED = -1;

    private static final String TAG = "ScrollCaptureViewSupport";

    private static final boolean WAIT_FOR_ANIMATION = true;

    private final WeakReference<V> mWeakView;
    private final ScrollCaptureViewHelper<V> mViewHelper;
    private ViewRenderer mRenderer;
@@ -99,12 +103,16 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa
        V view = mWeakView.get();
        if (view == null || !view.isVisibleToUser()) {
            // Signal to the controller that we have a problem and can't continue.
            session.notifyBufferSent(0, null);
            session.notifyBufferSent(NO_FRAME_PRODUCED, new Rect());
            return;
        }
        // Ask the view to scroll as needed to bring this area into view.
        ScrollResult scrollResult = mViewHelper.onScrollRequested(view, session.getScrollBounds(),
                requestRect);
        if (scrollResult.availableArea.isEmpty()) {
            session.notifyBufferSent(NO_FRAME_PRODUCED, scrollResult.availableArea);
            return;
        }
        view.invalidate(); // don't wait for vsync

        // For image capture, shift back by scrollDelta to arrive at the location within the view
@@ -112,8 +120,19 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa
        Rect viewCaptureArea = new Rect(scrollResult.availableArea);
        viewCaptureArea.offset(0, -scrollResult.scrollDelta);

        mRenderer.renderView(view, viewCaptureArea, mUiHandler,
                (frameNumber) -> session.notifyBufferSent(frameNumber, scrollResult.availableArea));
        if (WAIT_FOR_ANIMATION) {
            Log.d(TAG, "render: delaying until animation");
            view.postOnAnimation(() ->  {
                Log.d(TAG, "postOnAnimation(): rendering now");
                long resultFrame = mRenderer.renderView(view, viewCaptureArea);
                Log.d(TAG, "notifyBufferSent: " + scrollResult.availableArea);

                session.notifyBufferSent(resultFrame, new Rect(scrollResult.availableArea));
            });
        } else {
            long resultFrame = mRenderer.renderView(view, viewCaptureArea);
            session.notifyBufferSent(resultFrame, new Rect(scrollResult.availableArea));
        }
    }

    @Override
@@ -132,8 +151,7 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa

    /**
     * Internal helper class which assists in rendering sections of the view hierarchy relative to a
     * given view. Used by framework implementations of ScrollCaptureHandler to render and dispatch
     * image requests.
     * given view.
     */
    static final class ViewRenderer {
        // alpha, "reasonable default" from Javadoc
@@ -157,14 +175,11 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa
        private final Matrix mTempMatrix = new Matrix();
        private final int[] mTempLocation = new int[2];
        private long mLastRenderedSourceDrawingId = -1;


        public interface FrameCompleteListener {
            void onFrameComplete(long frameNumber);
        }
        private Surface mSurface;

        ViewRenderer() {
            mRenderer = new HardwareRenderer();
            mRenderer.setName("ScrollCapture");
            mCaptureRenderNode = new RenderNode("ScrollCaptureRoot");
            mRenderer.setContentRoot(mCaptureRenderNode);

@@ -173,6 +188,7 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa
        }

        public void setSurface(Surface surface) {
            mSurface = surface;
            mRenderer.setSurface(surface);
        }

@@ -223,20 +239,38 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa
            mCaptureRenderNode.endRecording();
        }

        public void renderView(View view, Rect sourceRect, Handler handler,
                FrameCompleteListener frameListener) {
        public long renderView(View view, Rect sourceRect) {
            if (updateForView(view)) {
                setupLighting(view);
            }
            view.invalidate();
            updateRootNode(view, sourceRect);
            HardwareRenderer.FrameRenderRequest request = mRenderer.createRenderRequest();
            request.setVsyncTime(SystemClock.elapsedRealtimeNanos());
            // private API b/c request.setFrameCommitCallback does not provide access to frameNumber
            mRenderer.setFrameCompleteCallback(
                    frameNr -> handler.post(() -> frameListener.onFrameComplete(frameNr)));
            long timestamp = System.nanoTime();
            request.setVsyncTime(timestamp);

            // Would be nice to access nextFrameNumber from HwR without having to hold on to Surface
            final long frameNumber = mSurface.getNextFrameNumber();

            // Block until a frame is presented to the Surface
            request.setWaitForPresent(true);
            request.syncAndDraw();

            switch (request.syncAndDraw()) {
                case HardwareRenderer.SYNC_OK:
                case HardwareRenderer.SYNC_REDRAW_REQUESTED:
                    return frameNumber;

                case HardwareRenderer.SYNC_FRAME_DROPPED:
                    Log.e(TAG, "syncAndDraw(): SYNC_FRAME_DROPPED !");
                    break;
                case HardwareRenderer.SYNC_LOST_SURFACE_REWARD_IF_FOUND:
                    Log.e(TAG, "syncAndDraw(): SYNC_LOST_SURFACE !");
                    break;
                case HardwareRenderer.SYNC_CONTEXT_IS_STOPPED:
                    Log.e(TAG, "syncAndDraw(): SYNC_CONTEXT_IS_STOPPED !");
                    break;
            }
            return NO_FRAME_PRODUCED;
        }

        public void trimMemory() {
@@ -244,6 +278,7 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa
        }

        public void destroy() {
            mSurface = null;
            mRenderer.destroy();
        }

@@ -254,6 +289,5 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa
            mTempMatrix.mapRect(mTempRectF);
            mTempRectF.round(outRect);
        }

    }
}
+12 −8
Original line number Diff line number Diff line
@@ -57,10 +57,6 @@ public class ScrollViewCaptureHelper implements ScrollCaptureViewHelper<ViewGrou

    public ScrollResult onScrollRequested(@NonNull ViewGroup view, Rect scrollBounds,
            Rect requestRect) {
        final View contentView = view.getChildAt(0); // returns null, does not throw IOOBE
        if (contentView == null) {
            return null;
        }
        /*
               +---------+ <----+ Content [25,25 - 275,1025] (w=250,h=1000)
               |         |
@@ -88,9 +84,6 @@ public class ScrollViewCaptureHelper implements ScrollCaptureViewHelper<ViewGrou
            \__ Requested Bounds[0,300 - 200,400] (200x100)
       */

        ScrollResult result = new ScrollResult();
        result.requestedArea = new Rect(requestRect);

        // 0) adjust the requestRect to account for scroll change since start
        //
        //  Scroll Bounds[50,50 - 250,250]  (w=200,h=200)
@@ -99,6 +92,17 @@ public class ScrollViewCaptureHelper implements ScrollCaptureViewHelper<ViewGrou
        // (y-100) (scrollY - mStartScrollY)
        int scrollDelta = view.getScrollY() - mStartScrollY;

        final ScrollResult result = new ScrollResult();
        result.requestedArea = new Rect(requestRect);
        result.scrollDelta = scrollDelta;
        result.availableArea = new Rect();

        final View contentView = view.getChildAt(0); // returns null, does not throw IOOBE
        if (contentView == null) {
            // No child view? Cannot continue.
            return result;
        }

        //  1) Translate request rect to make it relative to container view
        //
        //  Container View [0,0 - 300,300] (scrollY=200)
@@ -133,7 +137,7 @@ public class ScrollViewCaptureHelper implements ScrollCaptureViewHelper<ViewGrou
        // TODO: crop capture area to avoid occlusions/minimize scroll changes

        Point offset = new Point();
        final Rect available = new Rect(requestedContentBounds); // empty
        final Rect available = new Rect(requestedContentBounds);
        if (!view.getChildVisibleRect(contentView, available, offset)) {
            available.setEmpty();
            result.availableArea = available;
Loading