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