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.util.Log;
22 import android.view.View;
23 import android.view.ViewGroup;
24 import android.view.ViewParent;
25 
26 /**
27  * ScrollCapture for RecyclerView and <i>RecyclerView-like</i> ViewGroups.
28  * <p>
29  * Requirements for proper operation:
30  * <ul>
31  * <li>at least one visible child view</li>
32  * <li>scrolls by pixels in response to {@link View#scrollBy(int, int)}.
33  * <li>reports ability to scroll with {@link View#canScrollVertically(int)}
34  * <li>properly implements {@link ViewParent#requestChildRectangleOnScreen(View, Rect, boolean)}
35  * </ul>
36  *
37  * @see ScrollCaptureViewSupport
38  */
39 public class RecyclerViewCaptureHelper implements ScrollCaptureViewHelper<ViewGroup> {
40     private static final String TAG = "RVCaptureHelper";
41 
42     private int mScrollDelta;
43     private boolean mScrollBarWasEnabled;
44     private int mOverScrollMode;
45 
46     @Override
onPrepareForStart(@onNull ViewGroup view, Rect scrollBounds)47     public void onPrepareForStart(@NonNull ViewGroup view, Rect scrollBounds) {
48         mScrollDelta = 0;
49 
50         mOverScrollMode = view.getOverScrollMode();
51         view.setOverScrollMode(View.OVER_SCROLL_NEVER);
52 
53         mScrollBarWasEnabled = view.isVerticalScrollBarEnabled();
54         view.setVerticalScrollBarEnabled(false);
55     }
56 
57     @Override
onScrollRequested(@onNull ViewGroup recyclerView, Rect scrollBounds, Rect requestRect)58     public ScrollResult onScrollRequested(@NonNull ViewGroup recyclerView, Rect scrollBounds,
59             Rect requestRect) {
60         ScrollResult result = new ScrollResult();
61         result.requestedArea = new Rect(requestRect);
62         result.scrollDelta = mScrollDelta;
63         result.availableArea = new Rect(); // empty
64 
65         if (!recyclerView.isVisibleToUser() || recyclerView.getChildCount() == 0) {
66             Log.w(TAG, "recyclerView is empty or not visible, cannot continue");
67             return result; // result.availableArea == empty Rect
68         }
69 
70         // move from scrollBounds-relative to parent-local coordinates
71         Rect requestedContainerBounds = new Rect(requestRect);
72         requestedContainerBounds.offset(0, -mScrollDelta);
73         requestedContainerBounds.offset(scrollBounds.left, scrollBounds.top);
74         // requestedContainerBounds is now in recyclerview-local coordinates
75 
76         // Save a copy for later
77         View anchor = findChildNearestTarget(recyclerView, requestedContainerBounds);
78         if (anchor == null) {
79             Log.w(TAG, "Failed to locate anchor view");
80             return result; // result.availableArea == empty rect
81         }
82 
83         Rect requestedContentBounds = new Rect(requestedContainerBounds);
84         recyclerView.offsetRectIntoDescendantCoords(anchor, requestedContentBounds);
85 
86         int prevAnchorTop = anchor.getTop();
87         // Note: requestChildRectangleOnScreen may modify rectangle, must pass pass in a copy here
88         Rect input = new Rect(requestedContentBounds);
89         // Expand input rect to get the requested rect to be in the center
90         int remainingHeight = recyclerView.getHeight() - recyclerView.getPaddingTop()
91                 - recyclerView.getPaddingBottom() - input.height();
92         if (remainingHeight > 0) {
93             input.inset(0, -remainingHeight / 2);
94         }
95 
96         if (recyclerView.requestChildRectangleOnScreen(anchor, input, true)) {
97             int scrolled = prevAnchorTop - anchor.getTop(); // inverse of movement
98             mScrollDelta += scrolled; // view.top-- is equivalent to parent.scrollY++
99             result.scrollDelta = mScrollDelta;
100         }
101 
102         requestedContainerBounds.set(requestedContentBounds);
103         recyclerView.offsetDescendantRectToMyCoords(anchor, requestedContainerBounds);
104 
105         Rect recyclerLocalVisible = new Rect(scrollBounds);
106         recyclerView.getLocalVisibleRect(recyclerLocalVisible);
107 
108         if (!requestedContainerBounds.intersect(recyclerLocalVisible)) {
109             // Requested area is still not visible
110             return result;
111         }
112         Rect available = new Rect(requestedContainerBounds);
113         available.offset(-scrollBounds.left, -scrollBounds.top);
114         available.offset(0, mScrollDelta);
115         result.availableArea = available;
116         return result;
117     }
118 
119     /**
120      * Find a view that is located "closest" to targetRect. Returns the first view to fully
121      * vertically overlap the target targetRect. If none found, returns the view with an edge
122      * nearest the target targetRect.
123      *
124      * @param parent the parent vertical layout
125      * @param targetRect a rectangle in local coordinates of <code>parent</code>
126      * @return a child view within parent matching the criteria or null
127      */
findChildNearestTarget(ViewGroup parent, Rect targetRect)128     static View findChildNearestTarget(ViewGroup parent, Rect targetRect) {
129         View selected = null;
130         int minCenterDistance = Integer.MAX_VALUE;
131         int maxOverlap = 0;
132 
133         // allowable center-center distance, relative to targetRect.
134         // if within this range, taller views are preferred
135         final float preferredRangeFromCenterPercent = 0.25f;
136         final int preferredDistance =
137                 (int) (preferredRangeFromCenterPercent * targetRect.height());
138 
139         Rect parentLocalVis = new Rect();
140         parent.getLocalVisibleRect(parentLocalVis);
141 
142         Rect frame = new Rect();
143         for (int i = 0; i < parent.getChildCount(); i++) {
144             final View child = parent.getChildAt(i);
145             child.getHitRect(frame);
146 
147             if (child.getVisibility() != View.VISIBLE) {
148                 continue;
149             }
150 
151             int centerDistance = Math.abs(targetRect.centerY() - frame.centerY());
152 
153             if (centerDistance < minCenterDistance) {
154                 // closer to center
155                 minCenterDistance = centerDistance;
156                 selected = child;
157             } else if (frame.intersect(targetRect) && (frame.height() > preferredDistance)) {
158                 // within X% pixels of center, but taller
159                 selected = child;
160             }
161         }
162         return selected;
163     }
164 
165     @Override
onPrepareForEnd(@onNull ViewGroup view)166     public void onPrepareForEnd(@NonNull ViewGroup view) {
167         // Restore original position and state
168         view.scrollBy(0, -mScrollDelta);
169         view.setOverScrollMode(mOverScrollMode);
170         view.setVerticalScrollBarEnabled(mScrollBarWasEnabled);
171     }
172 }
173