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 static android.view.flags.Flags.scrollCaptureRelaxScrollViewCriteria; 20 21 import android.annotation.Nullable; 22 import android.content.Context; 23 import android.content.res.Resources; 24 import android.graphics.Point; 25 import android.graphics.Rect; 26 import android.util.Log; 27 import android.view.ScrollCaptureCallback; 28 import android.view.View; 29 import android.view.ViewGroup; 30 import android.webkit.WebView; 31 import android.widget.ListView; 32 33 /** 34 * Provides built-in framework level Scroll Capture support for standard scrolling Views. 35 */ 36 public class ScrollCaptureInternal { 37 private static final String TAG = "ScrollCaptureInternal"; 38 39 // Log found scrolling views 40 private static final boolean DEBUG = false; 41 42 // Log all investigated views, as well as heuristic checks 43 private static final boolean DEBUG_VERBOSE = false; 44 45 private static final int UP = -1; 46 private static final int DOWN = 1; 47 48 /** 49 * Cannot scroll according to {@link View#canScrollVertically}. 50 */ 51 public static final int TYPE_FIXED = 0; 52 53 /** 54 * Moves the viewport across absolute positioned child views using the scrollY property. 55 */ 56 public static final int TYPE_SCROLLING = 1; 57 58 /** 59 * Slides child views through the viewport by translating their layout positions with {@link 60 * View#offsetTopAndBottom(int)}. Manages Child view lifecycle, creating as needed and 61 * binding views to data from an adapter. Views are reused whenever possible. 62 */ 63 public static final int TYPE_RECYCLING = 2; 64 65 /** 66 * Unknown scrollable view with no child views (or not a subclass of ViewGroup). 67 */ 68 public static final int TYPE_OPAQUE = 3; 69 70 /** 71 * Performs tests on the given View and determines: 72 * 1. If scrolling is possible 73 * 2. What mechanisms are used for scrolling. 74 * <p> 75 * This needs to be fast and not alloc memory. It's called on everything in the tree not marked 76 * as excluded during scroll capture search. 77 */ detectScrollingType(View view)78 public static int detectScrollingType(View view) { 79 // Confirm that it can scroll. 80 if (!(view.canScrollVertically(DOWN) || view.canScrollVertically(UP))) { 81 // Nothing to scroll here, move along. 82 if (DEBUG_VERBOSE) { 83 Log.v(TAG, "hint: cannot be scrolled"); 84 } 85 return TYPE_FIXED; 86 } 87 if (DEBUG_VERBOSE) { 88 Log.v(TAG, "hint: can be scrolled up or down"); 89 } 90 // Must be a ViewGroup 91 if (!(view instanceof ViewGroup)) { 92 if (DEBUG_VERBOSE) { 93 Log.v(TAG, "hint: not a subclass of ViewGroup"); 94 } 95 return TYPE_OPAQUE; 96 } 97 if (DEBUG_VERBOSE) { 98 Log.v(TAG, "hint: is a subclass of ViewGroup"); 99 } 100 // Flag: Optionally allow ScrollView-like ViewGroups which have more than one child view. 101 if (!scrollCaptureRelaxScrollViewCriteria()) { 102 // ScrollViews accept only a single child. 103 if (((ViewGroup) view).getChildCount() > 1) { 104 if (DEBUG_VERBOSE) { 105 Log.v(TAG, "hint: scrollable with multiple children"); 106 } 107 return TYPE_RECYCLING; 108 } 109 } 110 // At least one child view is required. 111 if (((ViewGroup) view).getChildCount() == 0) { 112 Log.w(TAG, "scrollable but no children!"); 113 return TYPE_OPAQUE; 114 } 115 if (DEBUG_VERBOSE) { 116 Log.v(TAG, "hint: single child view"); 117 } 118 // Because recycling containers don't use scrollY, a non-zero value means Scroll view. 119 if (view.getScrollY() != 0) { 120 if (DEBUG_VERBOSE) { 121 Log.v(TAG, "hint: scrollY != 0"); 122 } 123 return TYPE_SCROLLING; 124 } 125 Log.v(TAG, "hint: scrollY == 0"); 126 // Since scrollY cannot be negative, this means a Recycling view. 127 if (view.canScrollVertically(UP)) { 128 if (DEBUG_VERBOSE) { 129 Log.v(TAG, "hint: able to scroll up"); 130 } 131 return TYPE_RECYCLING; 132 } 133 if (DEBUG_VERBOSE) { 134 Log.v(TAG, "hint: cannot be scrolled up"); 135 } 136 137 // canScrollVertically(UP) == false, getScrollY() == 0, getChildCount() >= 1. 138 // For Recycling containers, this should be a no-op (RecyclerView logs a warning) 139 view.scrollTo(view.getScrollX(), 1); 140 141 // A scrolling container would have moved by 1px. 142 if (view.getScrollY() == 1) { 143 view.scrollTo(view.getScrollX(), 0); 144 if (DEBUG_VERBOSE) { 145 Log.v(TAG, "hint: scrollTo caused scrollY to change"); 146 } 147 return TYPE_SCROLLING; 148 } 149 if (DEBUG_VERBOSE) { 150 Log.v(TAG, "hint: scrollTo did not cause scrollY to change"); 151 } 152 return TYPE_RECYCLING; 153 } 154 155 /** 156 * Creates a scroll capture callback for the given view if possible. 157 * 158 * @param view the view to capture 159 * @param localVisibleRect the visible area of the given view in local coordinates, as supplied 160 * by the view parent 161 * @param positionInWindow the offset of localVisibleRect within the window 162 * @return a new callback or null if the View isn't supported 163 */ 164 @Nullable requestCallback(View view, Rect localVisibleRect, Point positionInWindow)165 public ScrollCaptureCallback requestCallback(View view, Rect localVisibleRect, 166 Point positionInWindow) { 167 // Nothing to see here yet. 168 if (DEBUG_VERBOSE) { 169 Log.v(TAG, "scroll capture: checking " + view.getClass().getName() 170 + "[" + resolveId(view.getContext(), view.getId()) + "]"); 171 } 172 int i = detectScrollingType(view); 173 switch (i) { 174 case TYPE_SCROLLING: 175 if (DEBUG) { 176 Log.d(TAG, "scroll capture: FOUND " + view.getClass().getName() 177 + "[" + resolveId(view.getContext(), view.getId()) + "]" 178 + " -> TYPE_SCROLLING"); 179 } 180 return new ScrollCaptureViewSupport<>((ViewGroup) view, 181 new ScrollViewCaptureHelper()); 182 case TYPE_RECYCLING: 183 if (DEBUG) { 184 Log.d(TAG, "scroll capture: FOUND " + view.getClass().getName() 185 + "[" + resolveId(view.getContext(), view.getId()) + "]" 186 + " -> TYPE_RECYCLING"); 187 } 188 if (view instanceof ListView) { 189 // ListView is special. 190 return new ScrollCaptureViewSupport<>((ListView) view, 191 new ListViewCaptureHelper()); 192 } 193 return new ScrollCaptureViewSupport<>((ViewGroup) view, 194 new RecyclerViewCaptureHelper()); 195 case TYPE_OPAQUE: 196 if (DEBUG) { 197 Log.d(TAG, "scroll capture: FOUND " + view.getClass().getName() 198 + "[" + resolveId(view.getContext(), view.getId()) + "]" 199 + " -> TYPE_OPAQUE"); 200 } 201 if (view instanceof WebView) { 202 Log.d(TAG, "scroll capture: Using WebView support"); 203 return new ScrollCaptureViewSupport<>((WebView) view, 204 new WebViewCaptureHelper()); 205 } 206 break; 207 case TYPE_FIXED: 208 // ignore 209 break; 210 211 } 212 return null; 213 } 214 215 // Lifted from ViewDebug (package protected) 216 formatIntToHexString(int value)217 private static String formatIntToHexString(int value) { 218 return "0x" + Integer.toHexString(value).toUpperCase(); 219 } 220 resolveId(Context context, int id)221 static String resolveId(Context context, int id) { 222 String fieldValue; 223 final Resources resources = context.getResources(); 224 if (id >= 0) { 225 try { 226 fieldValue = resources.getResourceTypeName(id) + '/' 227 + resources.getResourceEntryName(id); 228 } catch (Resources.NotFoundException e) { 229 fieldValue = "id/" + formatIntToHexString(id); 230 } 231 } else { 232 fieldValue = "NO_ID"; 233 } 234 return fieldValue; 235 } 236 } 237