1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.internal.view;
18 
19 import android.annotation.NonNull;
20 import android.graphics.Rect;
21 import android.os.CancellationSignal;
22 import android.util.Log;
23 import android.view.View;
24 import android.view.ViewGroup;
25 import android.view.ViewParent;
26 
27 import java.util.function.Consumer;
28 
29 /**
30  * ScrollCapture for RecyclerView and <i>RecyclerView-like</i> ViewGroups.
31  * <p>
32  * Requirements for proper operation:
33  * <ul>
34  * <li>at least one visible child view</li>
35  * <li>scrolls by pixels in response to {@link View#scrollBy(int, int)}.
36  * <li>reports ability to scroll with {@link View#canScrollVertically(int)}
37  * <li>properly implements {@link ViewParent#requestChildRectangleOnScreen(View, Rect, boolean)}
38  * </ul>
39  *
40  * @see ScrollCaptureViewSupport
41  */
42 public class RecyclerViewCaptureHelper implements ScrollCaptureViewHelper<ViewGroup> {
43     private static final String TAG = "RVCaptureHelper";
44 
45     private int mScrollDelta;
46     private boolean mScrollBarWasEnabled;
47     private int mOverScrollMode;
48 
49     @Override
onAcceptSession(@onNull ViewGroup view)50     public boolean onAcceptSession(@NonNull ViewGroup view) {
51         return view.isVisibleToUser()
52                 && (view.canScrollVertically(UP) || view.canScrollVertically(DOWN));
53     }
54 
55     @Override
onPrepareForStart(@onNull ViewGroup view, Rect scrollBounds)56     public void onPrepareForStart(@NonNull ViewGroup view, Rect scrollBounds) {
57         mScrollDelta = 0;
58 
59         mOverScrollMode = view.getOverScrollMode();
60         view.setOverScrollMode(View.OVER_SCROLL_NEVER);
61 
62         mScrollBarWasEnabled = view.isVerticalScrollBarEnabled();
63         view.setVerticalScrollBarEnabled(false);
64     }
65 
66     @Override
onScrollRequested(@onNull ViewGroup recyclerView, Rect scrollBounds, Rect requestRect, CancellationSignal signal, Consumer<ScrollResult> resultConsumer)67     public void onScrollRequested(@NonNull ViewGroup recyclerView, Rect scrollBounds,
68             Rect requestRect, CancellationSignal signal, Consumer<ScrollResult> resultConsumer) {
69         ScrollResult result = new ScrollResult();
70         result.requestedArea = new Rect(requestRect);
71         result.scrollDelta = mScrollDelta;
72         result.availableArea = new Rect(); // empty
73 
74         if (!recyclerView.isVisibleToUser() || recyclerView.getChildCount() == 0) {
75             Log.w(TAG, "recyclerView is empty or not visible, cannot continue");
76             resultConsumer.accept(result); // result.availableArea == empty Rect
77             return;
78         }
79 
80         // move from scrollBounds-relative to parent-local coordinates
81         Rect requestedContainerBounds = new Rect(requestRect);
82         requestedContainerBounds.offset(0, -mScrollDelta);
83         requestedContainerBounds.offset(scrollBounds.left, scrollBounds.top);
84         // requestedContainerBounds is now in recyclerview-local coordinates
85 
86         // Save a copy for later
87         View anchor = findChildNearestTarget(recyclerView, requestedContainerBounds);
88         if (anchor == null) {
89             Log.w(TAG, "Failed to locate anchor view");
90             resultConsumer.accept(result); // result.availableArea == empty rect
91             return;
92         }
93 
94         Rect requestedContentBounds = new Rect(requestedContainerBounds);
95         recyclerView.offsetRectIntoDescendantCoords(anchor, requestedContentBounds);
96 
97         int prevAnchorTop = anchor.getTop();
98         // Note: requestChildRectangleOnScreen may modify rectangle, must pass pass in a copy here
99         Rect input = new Rect(requestedContentBounds);
100         // Expand input rect to get the requested rect to be in the center
101         int remainingHeight = recyclerView.getHeight() - recyclerView.getPaddingTop()
102                 - recyclerView.getPaddingBottom() - input.height();
103         if (remainingHeight > 0) {
104             input.inset(0, -remainingHeight / 2);
105         }
106 
107         if (recyclerView.requestChildRectangleOnScreen(anchor, input, true)) {
108             if (anchor.getParent() == null) {
109                 // BUG(b/239050369): Check if the tracked anchor view is still attached.
110                 Log.w(TAG, "Bug: anchor view " + anchor + " is detached after scrolling");
111                 resultConsumer.accept(result); // empty result
112                 return;
113             }
114 
115             int scrolled = prevAnchorTop - anchor.getTop(); // inverse of movement
116             mScrollDelta += scrolled; // view.top-- is equivalent to parent.scrollY++
117             result.scrollDelta = mScrollDelta;
118         }
119 
120         requestedContainerBounds.set(requestedContentBounds);
121         recyclerView.offsetDescendantRectToMyCoords(anchor, requestedContainerBounds);
122 
123         Rect recyclerLocalVisible = new Rect(scrollBounds);
124         recyclerView.getLocalVisibleRect(recyclerLocalVisible);
125 
126         if (!requestedContainerBounds.intersect(recyclerLocalVisible)) {
127             // Requested area is still not visible
128             resultConsumer.accept(result);
129             return;
130         }
131         Rect available = new Rect(requestedContainerBounds);
132         available.offset(-scrollBounds.left, -scrollBounds.top);
133         available.offset(0, mScrollDelta);
134         result.availableArea = available;
135         resultConsumer.accept(result);
136     }
137 
138     /**
139      * Find a view that is located "closest" to targetRect. Returns the first view to fully
140      * vertically overlap the target targetRect. If none found, returns the view with an edge
141      * nearest the target targetRect.
142      *
143      * @param parent the parent vertical layout
144      * @param targetRect a rectangle in local coordinates of <code>parent</code>
145      * @return a child view within parent matching the criteria or null
146      */
findChildNearestTarget(ViewGroup parent, Rect targetRect)147     static View findChildNearestTarget(ViewGroup parent, Rect targetRect) {
148         View selected = null;
149         int minCenterDistance = Integer.MAX_VALUE;
150         int maxOverlap = 0;
151 
152         // allowable center-center distance, relative to targetRect.
153         // if within this range, taller views are preferred
154         final float preferredRangeFromCenterPercent = 0.25f;
155         final int preferredDistance =
156                 (int) (preferredRangeFromCenterPercent * targetRect.height());
157 
158         Rect parentLocalVis = new Rect();
159         parent.getLocalVisibleRect(parentLocalVis);
160 
161         Rect frame = new Rect();
162         for (int i = 0; i < parent.getChildCount(); i++) {
163             final View child = parent.getChildAt(i);
164             child.getHitRect(frame);
165 
166             if (child.getVisibility() != View.VISIBLE) {
167                 continue;
168             }
169 
170             int centerDistance = Math.abs(targetRect.centerY() - frame.centerY());
171 
172             if (centerDistance < minCenterDistance) {
173                 // closer to center
174                 minCenterDistance = centerDistance;
175                 selected = child;
176             } else if (frame.intersect(targetRect) && (frame.height() > preferredDistance)) {
177                 // within X% pixels of center, but taller
178                 selected = child;
179             }
180         }
181         return selected;
182     }
183 
184     @Override
onPrepareForEnd(@onNull ViewGroup view)185     public void onPrepareForEnd(@NonNull ViewGroup view) {
186         // Restore original position and state
187         view.scrollBy(0, -mScrollDelta);
188         view.setOverScrollMode(mOverScrollMode);
189         view.setVerticalScrollBarEnabled(mScrollBarWasEnabled);
190     }
191 }
192