• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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