Merge "Adds scroll capture support for RecyclerView"

This commit is contained in:
Mark Renouf
2020-10-09 23:55:00 +00:00
committed by Android (Google) Code Review
8 changed files with 789 additions and 36 deletions

View File

@@ -0,0 +1,202 @@
/*
* 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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -0,0 +1,343 @@
/*
* 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 static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import static android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
import static android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.fail;
import android.app.Instrumentation;
import android.content.Context;
import android.graphics.Color;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.test.annotation.UiThreadTest;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import com.android.internal.view.ScrollCaptureViewHelper.ScrollResult;
import com.android.internal.widget.LinearLayoutManager;
import com.android.internal.widget.RecyclerView;
import com.google.common.truth.Truth;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.Random;
@RunWith(AndroidJUnit4.class)
public class RecyclerViewCaptureHelperTest {
private static final int CHILD_VIEWS = 12;
private static final int CHILD_VIEW_HEIGHT = 300;
private static final int WINDOW_WIDTH = 800;
private static final int WINDOW_HEIGHT = 1200;
private static final int CAPTURE_HEIGHT = 600;
private FrameLayout mParent;
private RecyclerView mTarget;
private WindowManager mWm;
private WindowManager.LayoutParams mWindowLayoutParams;
private Context mContext;
private float mDensity;
private LinearLayoutManager mLinearLayoutManager;
private Instrumentation mInstrumentation;
@Before
@UiThreadTest
public void setUp() {
mInstrumentation = InstrumentationRegistry.getInstrumentation();
mContext = mInstrumentation.getContext();
mDensity = mContext.getResources().getDisplayMetrics().density;
mParent = new FrameLayout(mContext);
mTarget = new RecyclerView(mContext);
mParent.addView(mTarget, new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT));
mTarget.setAdapter(new TestAdapter());
mLinearLayoutManager =
new LinearLayoutManager(mContext, LinearLayoutManager.VERTICAL, false);
mTarget.setLayoutManager(mLinearLayoutManager);
mWm = mContext.getSystemService(WindowManager.class);
// Setup the window that we are going to use
mWindowLayoutParams = new WindowManager.LayoutParams(WINDOW_WIDTH, WINDOW_HEIGHT,
TYPE_APPLICATION_OVERLAY, FLAG_NOT_TOUCHABLE, PixelFormat.OPAQUE);
mWindowLayoutParams.setTitle("ScrollViewCaptureHelper");
mWindowLayoutParams.gravity = Gravity.CENTER;
mWm.addView(mParent, mWindowLayoutParams);
}
@After
@UiThreadTest
public void tearDown() {
mWm.removeViewImmediate(mParent);
}
@Test
@UiThreadTest
public void onScrollRequested_up_fromTop() {
mTarget.scrollBy(0, -(WINDOW_HEIGHT * 3));
// mTarget.createSnapshot(new ViewDebug.HardwareCanvasProvider(), false);
RecyclerViewCaptureHelper rvc = new RecyclerViewCaptureHelper();
Rect scrollBounds = rvc.onComputeScrollBounds(mTarget);
rvc.onPrepareForStart(mTarget, scrollBounds);
assertThat(scrollBounds.height()).isGreaterThan(CAPTURE_HEIGHT);
Rect request = new Rect(0, -CAPTURE_HEIGHT, scrollBounds.width(), 0);
ScrollResult scrollResult = rvc.onScrollRequested(mTarget,
scrollBounds, request);
// The result is an empty rectangle and no scrolling, since it
// is not possible to physically scroll further up to make the
// requested area visible at all (it doesn't exist).
assertEmpty(scrollResult.availableArea);
}
@Test
@UiThreadTest
public void onScrollRequested_down_fromTop() {
mTarget.scrollBy(0, -(WINDOW_HEIGHT * 3));
RecyclerViewCaptureHelper rvc = new RecyclerViewCaptureHelper();
Rect scrollBounds = rvc.onComputeScrollBounds(mTarget);
rvc.onPrepareForStart(mTarget, scrollBounds);
assertThat(scrollBounds.height()).isGreaterThan(CAPTURE_HEIGHT);
// Capture between y = +1200 to +1800 pixels BELOW current top
Rect request = new Rect(0, WINDOW_HEIGHT, scrollBounds.width(),
WINDOW_HEIGHT + CAPTURE_HEIGHT);
ScrollResult scrollResult = rvc.onScrollRequested(mTarget, scrollBounds, request);
assertThat(request).isEqualTo(scrollResult.requestedArea);
assertThat(request).isEqualTo(scrollResult.availableArea);
assertThat(scrollResult.scrollDelta).isEqualTo(CAPTURE_HEIGHT);
assertAvailableAreaCompletelyVisible(scrollResult, mTarget);
}
@Test
@UiThreadTest
public void onScrollRequested_up_fromMiddle() {
mTarget.scrollBy(0, WINDOW_HEIGHT);
RecyclerViewCaptureHelper helper = new RecyclerViewCaptureHelper();
Rect scrollBounds = helper.onComputeScrollBounds(mTarget);
helper.onPrepareForStart(mTarget, scrollBounds);
Rect request = new Rect(0, -CAPTURE_HEIGHT, scrollBounds.width(), 0);
ScrollResult scrollResult = helper.onScrollRequested(mTarget, scrollBounds, request);
assertThat(request).isEqualTo(scrollResult.requestedArea);
assertThat(request).isEqualTo(scrollResult.availableArea);
assertThat(scrollResult.scrollDelta).isEqualTo(-CAPTURE_HEIGHT);
assertAvailableAreaCompletelyVisible(scrollResult, mTarget);
}
@Test
@UiThreadTest
public void onScrollRequested_down_fromMiddle() {
mTarget.scrollBy(0, WINDOW_HEIGHT);
RecyclerViewCaptureHelper helper = new RecyclerViewCaptureHelper();
Rect scrollBounds = helper.onComputeScrollBounds(mTarget);
helper.onPrepareForStart(mTarget, scrollBounds);
Rect request = new Rect(0, WINDOW_HEIGHT, scrollBounds.width(),
WINDOW_HEIGHT + CAPTURE_HEIGHT);
ScrollResult scrollResult = helper.onScrollRequested(mTarget, scrollBounds, request);
assertThat(request).isEqualTo(scrollResult.requestedArea);
assertThat(request).isEqualTo(scrollResult.availableArea);
assertThat(scrollResult.scrollDelta).isEqualTo(CAPTURE_HEIGHT);
assertAvailableAreaCompletelyVisible(scrollResult, mTarget);
}
@Test
@UiThreadTest
public void onScrollRequested_up_fromBottom() {
mTarget.scrollBy(0, WINDOW_HEIGHT * 2);
RecyclerViewCaptureHelper helper = new RecyclerViewCaptureHelper();
Rect scrollBounds = helper.onComputeScrollBounds(mTarget);
helper.onPrepareForStart(mTarget, scrollBounds);
Rect request = new Rect(0, -CAPTURE_HEIGHT, scrollBounds.width(), 0);
ScrollResult scrollResult = helper.onScrollRequested(mTarget, scrollBounds, request);
assertThat(request).isEqualTo(scrollResult.requestedArea);
assertThat(request).isEqualTo(scrollResult.availableArea);
assertThat(scrollResult.scrollDelta).isEqualTo(-CAPTURE_HEIGHT);
assertAvailableAreaCompletelyVisible(scrollResult, mTarget);
}
@Test
@UiThreadTest
public void onScrollRequested_down_fromBottom() {
mTarget.scrollBy(0, WINDOW_HEIGHT * 3);
RecyclerViewCaptureHelper rvc = new RecyclerViewCaptureHelper();
Rect scrollBounds = rvc.onComputeScrollBounds(mTarget);
rvc.onPrepareForStart(mTarget, scrollBounds);
Rect request = new Rect(0, WINDOW_HEIGHT, scrollBounds.width(),
WINDOW_HEIGHT + CAPTURE_HEIGHT);
ScrollResult scrollResult = rvc.onScrollRequested(mTarget,
scrollBounds, request);
Truth.assertThat(request).isEqualTo(scrollResult.requestedArea);
// The result is an empty rectangle and no scrolling, since it
// is not possible to physically scroll further down to make the
// requested area visible at all (it doesn't exist).
assertEmpty(scrollResult.availableArea);
}
@Test
@UiThreadTest
public void onScrollRequested_offTopEdge() {
mTarget.scrollBy(0, -(WINDOW_HEIGHT * 3));
RecyclerViewCaptureHelper helper = new RecyclerViewCaptureHelper();
Rect scrollBounds = helper.onComputeScrollBounds(mTarget);
helper.onPrepareForStart(mTarget, scrollBounds);
// Create a request which lands halfway off the top of the content
//from -1500 to -900, (starting at 1200 = -300 to +300 within the content)
int top = 0;
Rect request = new Rect(
0, top - (CAPTURE_HEIGHT / 2),
scrollBounds.width(), top + (CAPTURE_HEIGHT / 2));
ScrollResult scrollResult = helper.onScrollRequested(mTarget, scrollBounds, request);
assertThat(request).isEqualTo(scrollResult.requestedArea);
ScrollResult result = helper.onScrollRequested(mTarget, scrollBounds, request);
// The result is a partial result
Rect expectedResult = new Rect(request);
expectedResult.top += (CAPTURE_HEIGHT / 2); // top half clipped
assertThat(expectedResult).isEqualTo(result.availableArea);
assertThat(scrollResult.scrollDelta).isEqualTo(0);
assertAvailableAreaPartiallyVisible(scrollResult, mTarget);
}
@Test
@UiThreadTest
public void onScrollRequested_offBottomEdge() {
mTarget.scrollBy(0, WINDOW_HEIGHT * 2);
RecyclerViewCaptureHelper helper = new RecyclerViewCaptureHelper();
Rect scrollBounds = helper.onComputeScrollBounds(mTarget);
helper.onPrepareForStart(mTarget, scrollBounds);
// Create a request which lands halfway off the bottom of the content
//from 600 to to 1200, (starting at 2400 = 3000 to 3600 within the content)
int bottom = WINDOW_HEIGHT;
Rect request = new Rect(
0, bottom - (CAPTURE_HEIGHT / 2),
scrollBounds.width(), bottom + (CAPTURE_HEIGHT / 2));
ScrollResult result = helper.onScrollRequested(mTarget, scrollBounds, request);
Rect expectedResult = new Rect(request);
expectedResult.bottom -= 300; // bottom half clipped
assertThat(expectedResult).isEqualTo(result.availableArea);
assertThat(result.scrollDelta).isEqualTo(0);
assertAvailableAreaPartiallyVisible(result, mTarget);
}
static final class TestViewHolder extends RecyclerView.ViewHolder {
TestViewHolder(View itemView) {
super(itemView);
}
}
static final class TestAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private final Random mRandom = new Random();
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
return new TestViewHolder(new TextView(parent.getContext()));
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
TextView view = (TextView) holder.itemView;
view.setText("Child #" + position);
view.setTextColor(Color.WHITE);
view.setTextSize(30f);
view.setBackgroundColor(Color.rgb(mRandom.nextFloat(), mRandom.nextFloat(),
mRandom.nextFloat()));
view.setMinHeight(CHILD_VIEW_HEIGHT);
}
@Override
public int getItemCount() {
return CHILD_VIEWS;
}
}
static void assertEmpty(Rect r) {
if (r != null && !r.isEmpty()) {
fail("Not true that " + r + " is empty");
}
}
static Rect getVisibleRect(View v) {
Rect r = new Rect(0, 0, v.getWidth(), v.getHeight());
v.getLocalVisibleRect(r);
return r;
}
static void assertAvailableAreaCompletelyVisible(ScrollResult result, View container) {
Rect requested = new Rect(result.availableArea);
requested.offset(0, -result.scrollDelta); // make relative
Rect localVisible = getVisibleRect(container);
if (!localVisible.contains(requested)) {
fail("Not true that all of " + requested + " is contained by " + localVisible);
}
}
static void assertAvailableAreaPartiallyVisible(ScrollResult result, View container) {
Rect requested = new Rect(result.availableArea);
requested.offset(0, -result.scrollDelta); // make relative
Rect localVisible = getVisibleRect(container);
if (!Rect.intersects(localVisible, requested)) {
fail("Not true that any of " + requested + " is contained by " + localVisible);
}
}
}

View File

@@ -82,6 +82,10 @@
<activity android:name="com.android.systemui.screenshot.ScrollViewActivity"
android:exported="false" />
<activity android:name="com.android.systemui.screenshot.RecyclerViewActivity"
android:exported="false" />
<provider
android:name="androidx.lifecycle.ProcessLifecycleOwnerInitializer"
tools:replace="android:authorities"

View File

@@ -0,0 +1,80 @@
/*
* 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.systemui.screenshot;
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import android.app.Activity;
import android.graphics.Color;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.Nullable;
import com.android.internal.widget.LinearLayoutManager;
import com.android.internal.widget.RecyclerView;
import com.android.internal.widget.RecyclerView.LayoutParams;
import java.util.Random;
public class RecyclerViewActivity extends Activity {
public static final int CHILD_VIEW_HEIGHT = 300;
private static final int CHILD_VIEWS = 12;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
RecyclerView recyclerView = new RecyclerView(this);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
recyclerView.setAdapter(new TestAdapter());
recyclerView.setLayoutParams(new LayoutParams(MATCH_PARENT, MATCH_PARENT));
setContentView(recyclerView);
}
static final class TestViewHolder extends RecyclerView.ViewHolder {
TestViewHolder(View itemView) {
super(itemView);
}
}
static final class TestAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private final Random mRandom = new Random();
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
return new TestViewHolder(new TextView(parent.getContext()));
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
TextView view = (TextView) holder.itemView;
view.setText("Child #" + position);
view.setTextColor(Color.WHITE);
view.setTextSize(30f);
view.setBackgroundColor(
Color.rgb(mRandom.nextFloat(), mRandom.nextFloat(), mRandom.nextFloat()));
view.setMinHeight(CHILD_VIEW_HEIGHT);
}
@Override
public int getItemCount() {
return CHILD_VIEWS;
}
}
}