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