1 /* 2 * Copyright 2024 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 android.view; 18 19 import static com.android.internal.annotations.VisibleForTesting.Visibility.PACKAGE; 20 21 import android.annotation.Nullable; 22 import android.content.Context; 23 import android.graphics.Rect; 24 import android.os.Handler; 25 26 import androidx.annotation.NonNull; 27 28 import com.android.internal.annotations.VisibleForTesting; 29 30 import java.util.ArrayList; 31 import java.util.HashSet; 32 import java.util.List; 33 import java.util.Objects; 34 import java.util.Set; 35 36 /** 37 * {@link MotionEvent} processor that forwards scrolls on the letterbox area to the app's view 38 * hierarchy by translating the coordinates to app's inbound area. 39 * 40 * @hide 41 */ 42 @VisibleForTesting(visibility = PACKAGE) 43 public class LetterboxScrollProcessor { 44 45 private enum LetterboxScrollState { 46 AWAITING_GESTURE_START, 47 GESTURE_STARTED_IN_APP, 48 GESTURE_STARTED_OUTSIDE_APP, 49 SCROLLING_STARTED_OUTSIDE_APP 50 } 51 52 @NonNull private LetterboxScrollState mState = LetterboxScrollState.AWAITING_GESTURE_START; 53 @NonNull private final List<MotionEvent> mProcessedEvents = new ArrayList<>(); 54 55 @NonNull private final GestureDetector mScrollDetector; 56 @NonNull private final Context mContext; 57 58 /** IDs of events generated from this class */ 59 private final Set<Integer> mGeneratedEventIds = new HashSet<>(); 60 61 @VisibleForTesting(visibility = PACKAGE) LetterboxScrollProcessor(@onNull Context context, @Nullable Handler handler)62 public LetterboxScrollProcessor(@NonNull Context context, @Nullable Handler handler) { 63 mContext = context; 64 mScrollDetector = new GestureDetector(context, new ScrollListener(), handler); 65 } 66 67 /** 68 * Processes the MotionEvent. If the gesture is started in the app's bounds, or moves over the 69 * app then the motion events are not adjusted. Motion events from outside the app's 70 * bounds that are detected as a scroll gesture are adjusted to be over the app's bounds. 71 * Otherwise (if the events are outside the app's bounds and not part of a scroll gesture), the 72 * motion events are ignored. 73 * 74 * @param motionEvent The MotionEvent to process. 75 * @return The list of adjusted events, or null if no adjustments are needed. The list is empty 76 * if the event should be ignored. Do not keep a reference to the output as the list is reused. 77 */ 78 @Nullable 79 @VisibleForTesting(visibility = PACKAGE) processMotionEvent(@onNull MotionEvent motionEvent)80 public List<MotionEvent> processMotionEvent(@NonNull MotionEvent motionEvent) { 81 if (!motionEvent.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) { 82 // This is a non-pointer event that doesn't correspond to any location on the screen. 83 // Ignore it. 84 return null; 85 } 86 mProcessedEvents.clear(); 87 final Rect appBounds = getAppBounds(); 88 89 // Set state at the start of the gesture (when ACTION_DOWN is received) 90 if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) { 91 if (isOutsideAppBounds(motionEvent, appBounds)) { 92 mState = LetterboxScrollState.GESTURE_STARTED_OUTSIDE_APP; 93 } else { 94 mState = LetterboxScrollState.GESTURE_STARTED_IN_APP; 95 } 96 } 97 98 boolean makeNoAdjustments = false; 99 100 switch (mState) { 101 case AWAITING_GESTURE_START: 102 case GESTURE_STARTED_IN_APP: 103 // Do not adjust events if gesture is started in or is over the app. 104 makeNoAdjustments = true; 105 break; 106 107 case GESTURE_STARTED_OUTSIDE_APP: 108 // Send offset events to the scroll-detector. These events are not added to 109 // mProcessedEvents and are therefore ignored until detected as part of a scroll. 110 applyOffset(motionEvent, appBounds); 111 mScrollDetector.onTouchEvent(motionEvent); 112 // If scroll-detector triggered, then the state is changed to 113 // SCROLLING_STARTED_OUTSIDE_APP (scroll detector can only trigger after an 114 // ACTION_MOVE event is received). 115 if (mState == LetterboxScrollState.SCROLLING_STARTED_OUTSIDE_APP) { 116 // Also, include ACTION_MOVE motion event that triggered the scroll-detector. 117 mProcessedEvents.add(motionEvent); 118 } 119 break; 120 121 // Once scroll-detector has detected scrolling, offset is applied to the gesture. 122 case SCROLLING_STARTED_OUTSIDE_APP: 123 if (isOutsideAppBounds(motionEvent, appBounds)) { 124 // Offset the event to be over the app if the event is out-of-bounds. 125 applyOffset(motionEvent, appBounds); 126 } else { 127 // Otherwise, the gesture is already over the app so stop offsetting it. 128 mState = LetterboxScrollState.GESTURE_STARTED_IN_APP; 129 } 130 mProcessedEvents.add(motionEvent); 131 break; 132 } 133 134 // Reset state at the end of the gesture 135 if (motionEvent.getAction() == MotionEvent.ACTION_UP 136 || motionEvent.getAction() == MotionEvent.ACTION_CANCEL) { 137 mState = LetterboxScrollState.AWAITING_GESTURE_START; 138 } 139 140 return makeNoAdjustments ? null : mProcessedEvents; 141 } 142 143 /** 144 * Processes the InputEvent for compatibility before it is finished by calling 145 * InputEventReceiver#finishInputEvent(). 146 * 147 * @param motionEvent The MotionEvent to process. 148 * @return The motionEvent to finish, or null if it should not be finished. 149 */ 150 @Nullable 151 @VisibleForTesting(visibility = PACKAGE) processMotionEventBeforeFinish(@onNull MotionEvent motionEvent)152 public InputEvent processMotionEventBeforeFinish(@NonNull MotionEvent motionEvent) { 153 return mGeneratedEventIds.remove(motionEvent.getId()) ? null : motionEvent; 154 } 155 156 @NonNull getAppBounds()157 private Rect getAppBounds() { 158 return mContext.getResources().getConfiguration().windowConfiguration.getBounds(); 159 } 160 161 /** Checks whether the gesture is located on the letterbox area. */ isOutsideAppBounds(@onNull MotionEvent motionEvent, @NonNull Rect appBounds)162 private boolean isOutsideAppBounds(@NonNull MotionEvent motionEvent, @NonNull Rect appBounds) { 163 // The events are in the coordinate system of the ViewRootImpl (window). The window might 164 // not have the same dimensions as the app bounds - for example in case of Dialogs - thus 165 // `getRawX()` and `getRawY()` are used, with the absolute bounds (left, top, etc) instead 166 // of width and height. 167 // The event should be passed to the app if it has happened anywhere in the app area, 168 // irrespective of the current window size, therefore the app bounds are used instead of the 169 // current window. 170 return motionEvent.getRawX() < appBounds.left 171 || motionEvent.getRawX() >= appBounds.right 172 || motionEvent.getRawY() < appBounds.top 173 || motionEvent.getRawY() >= appBounds.bottom; 174 } 175 applyOffset(@onNull MotionEvent event, @NonNull Rect appBounds)176 private void applyOffset(@NonNull MotionEvent event, @NonNull Rect appBounds) { 177 float horizontalOffset = calculateOffset(event.getX(), appBounds.width()); 178 float verticalOffset = calculateOffset(event.getY(), appBounds.height()); 179 // Apply the offset to the motion event so it is over the app's view. 180 event.offsetLocation(horizontalOffset, verticalOffset); 181 } 182 calculateOffset(float eventCoord, int appBoundary)183 private float calculateOffset(float eventCoord, int appBoundary) { 184 if (eventCoord < 0) { 185 return -eventCoord; 186 } else if (eventCoord >= appBoundary) { 187 return -(eventCoord - appBoundary + 1); 188 } else { 189 return 0; 190 } 191 } 192 193 private class ScrollListener extends GestureDetector.SimpleOnGestureListener { ScrollListener()194 private ScrollListener() {} 195 196 @Override onScroll( @ullable MotionEvent actionDownEvent, @NonNull MotionEvent actionMoveEvent, float distanceX, float distanceY)197 public boolean onScroll( 198 @Nullable MotionEvent actionDownEvent, 199 @NonNull MotionEvent actionMoveEvent, 200 float distanceX, 201 float distanceY) { 202 // Inject in-bounds ACTION_DOWN event before continuing gesture with offset. 203 final MotionEvent newActionDownEvent = MotionEvent.obtain( 204 Objects.requireNonNull(actionDownEvent)); 205 Rect appBounds = getAppBounds(); 206 applyOffset(newActionDownEvent, appBounds); 207 mGeneratedEventIds.add(newActionDownEvent.getId()); 208 mProcessedEvents.add(newActionDownEvent); 209 210 // Change state when onScroll method is triggered - at this point, the passed event is 211 // known to be 'part of' a scroll gesture. 212 mState = LetterboxScrollState.SCROLLING_STARTED_OUTSIDE_APP; 213 214 return super.onScroll(actionDownEvent, actionMoveEvent, distanceX, distanceY); 215 } 216 } 217 } 218