1 /*
2  * Copyright (C) 2015 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.widget;
18 
19 import android.os.SystemClock;
20 import android.view.MotionEvent;
21 import android.view.View;
22 import android.view.ViewConfiguration;
23 import android.view.ViewParent;
24 
25 import com.android.internal.view.menu.ShowableListMenu;
26 
27 /**
28  * Abstract class that forwards touch events to a {@link ShowableListMenu}.
29  *
30  * @hide
31  */
32 public abstract class ForwardingListener
33         implements View.OnTouchListener, View.OnAttachStateChangeListener {
34 
35     /** Scaled touch slop, used for detecting movement outside bounds. */
36     private final float mScaledTouchSlop;
37 
38     /** Timeout before disallowing intercept on the source's parent. */
39     private final int mTapTimeout;
40 
41     /** Timeout before accepting a long-press to start forwarding. */
42     private final int mLongPressTimeout;
43 
44     /** Source view from which events are forwarded. */
45     private final View mSrc;
46 
47     /** Runnable used to prevent conflicts with scrolling parents. */
48     private Runnable mDisallowIntercept;
49 
50     /** Runnable used to trigger forwarding on long-press. */
51     private Runnable mTriggerLongPress;
52 
53     /** Whether this listener is currently forwarding touch events. */
54     private boolean mForwarding;
55 
56     /** The id of the first pointer down in the current event stream. */
57     private int mActivePointerId;
58 
ForwardingListener(View src)59     public ForwardingListener(View src) {
60         mSrc = src;
61         src.setLongClickable(true);
62         src.addOnAttachStateChangeListener(this);
63 
64         mScaledTouchSlop = ViewConfiguration.get(src.getContext()).getScaledTouchSlop();
65         mTapTimeout = ViewConfiguration.getTapTimeout();
66 
67         // Use a medium-press timeout. Halfway between tap and long-press.
68         mLongPressTimeout = (mTapTimeout + ViewConfiguration.getLongPressTimeout()) / 2;
69     }
70 
71     /**
72      * Returns the popup to which this listener is forwarding events.
73      * <p>
74      * Override this to return the correct popup. If the popup is displayed
75      * asynchronously, you may also need to override
76      * {@link #onForwardingStopped} to prevent premature cancellation of
77      * forwarding.
78      *
79      * @return the popup to which this listener is forwarding events
80      */
getPopup()81     public abstract ShowableListMenu getPopup();
82 
83     @Override
onTouch(View v, MotionEvent event)84     public boolean onTouch(View v, MotionEvent event) {
85         final boolean wasForwarding = mForwarding;
86         final boolean forwarding;
87         if (wasForwarding) {
88             forwarding = onTouchForwarded(event) || !onForwardingStopped();
89         } else {
90             forwarding = onTouchObserved(event) && onForwardingStarted();
91 
92             if (forwarding) {
93                 // Make sure we cancel any ongoing source event stream.
94                 final long now = SystemClock.uptimeMillis();
95                 final MotionEvent e = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL,
96                         0.0f, 0.0f, 0);
97                 mSrc.onTouchEvent(e);
98                 e.recycle();
99             }
100         }
101 
102         mForwarding = forwarding;
103         return forwarding || wasForwarding;
104     }
105 
106     @Override
onViewAttachedToWindow(View v)107     public void onViewAttachedToWindow(View v) {
108     }
109 
110     @Override
onViewDetachedFromWindow(View v)111     public void onViewDetachedFromWindow(View v) {
112         mForwarding = false;
113         mActivePointerId = MotionEvent.INVALID_POINTER_ID;
114 
115         if (mDisallowIntercept != null) {
116             mSrc.removeCallbacks(mDisallowIntercept);
117         }
118     }
119 
120     /**
121      * Called when forwarding would like to start.
122      * <p>
123      * By default, this will show the popup returned by {@link #getPopup()}.
124      * It may be overridden to perform another action, like clicking the
125      * source view or preparing the popup before showing it.
126      *
127      * @return true to start forwarding, false otherwise
128      */
onForwardingStarted()129     protected boolean onForwardingStarted() {
130         final ShowableListMenu popup = getPopup();
131         if (popup != null && !popup.isShowing()) {
132             popup.show();
133         }
134         return true;
135     }
136 
137     /**
138      * Called when forwarding would like to stop.
139      * <p>
140      * By default, this will dismiss the popup returned by
141      * {@link #getPopup()}. It may be overridden to perform some other
142      * action.
143      *
144      * @return true to stop forwarding, false otherwise
145      */
onForwardingStopped()146     protected boolean onForwardingStopped() {
147         final ShowableListMenu popup = getPopup();
148         if (popup != null && popup.isShowing()) {
149             popup.dismiss();
150         }
151         return true;
152     }
153 
154     /**
155      * Observes motion events and determines when to start forwarding.
156      *
157      * @param srcEvent motion event in source view coordinates
158      * @return true to start forwarding motion events, false otherwise
159      */
onTouchObserved(MotionEvent srcEvent)160     private boolean onTouchObserved(MotionEvent srcEvent) {
161         final View src = mSrc;
162         if (!src.isEnabled()) {
163             return false;
164         }
165 
166         final int actionMasked = srcEvent.getActionMasked();
167         switch (actionMasked) {
168             case MotionEvent.ACTION_DOWN:
169                 mActivePointerId = srcEvent.getPointerId(0);
170 
171                 if (mDisallowIntercept == null) {
172                     mDisallowIntercept = new DisallowIntercept();
173                 }
174                 src.postDelayed(mDisallowIntercept, mTapTimeout);
175 
176                 if (mTriggerLongPress == null) {
177                     mTriggerLongPress = new TriggerLongPress();
178                 }
179                 src.postDelayed(mTriggerLongPress, mLongPressTimeout);
180                 break;
181             case MotionEvent.ACTION_MOVE:
182                 final int activePointerIndex = srcEvent.findPointerIndex(mActivePointerId);
183                 if (activePointerIndex >= 0) {
184                     final float x = srcEvent.getX(activePointerIndex);
185                     final float y = srcEvent.getY(activePointerIndex);
186 
187                     // Has the pointer moved outside of the view?
188                     if (!src.pointInView(x, y, mScaledTouchSlop)) {
189                         clearCallbacks();
190 
191                         // Don't let the parent intercept our events.
192                         src.getParent().requestDisallowInterceptTouchEvent(true);
193                         return true;
194                     }
195                 }
196                 break;
197             case MotionEvent.ACTION_CANCEL:
198             case MotionEvent.ACTION_UP:
199                 clearCallbacks();
200                 break;
201         }
202 
203         return false;
204     }
205 
clearCallbacks()206     private void clearCallbacks() {
207         if (mTriggerLongPress != null) {
208             mSrc.removeCallbacks(mTriggerLongPress);
209         }
210 
211         if (mDisallowIntercept != null) {
212             mSrc.removeCallbacks(mDisallowIntercept);
213         }
214     }
215 
onLongPress()216     private void onLongPress() {
217         clearCallbacks();
218 
219         final View src = mSrc;
220         if (!src.isEnabled() || src.isLongClickable()) {
221             // Ignore long-press if the view is disabled or has its own
222             // handler.
223             return;
224         }
225 
226         if (!onForwardingStarted()) {
227             return;
228         }
229 
230         // Don't let the parent intercept our events.
231         src.getParent().requestDisallowInterceptTouchEvent(true);
232 
233         // Make sure we cancel any ongoing source event stream.
234         final long now = SystemClock.uptimeMillis();
235         final MotionEvent e = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 0, 0, 0);
236         src.onTouchEvent(e);
237         e.recycle();
238 
239         mForwarding = true;
240     }
241 
242     /**
243      * Handles forwarded motion events and determines when to stop
244      * forwarding.
245      *
246      * @param srcEvent motion event in source view coordinates
247      * @return true to continue forwarding motion events, false to cancel
248      */
onTouchForwarded(MotionEvent srcEvent)249     private boolean onTouchForwarded(MotionEvent srcEvent) {
250         final View src = mSrc;
251         final ShowableListMenu popup = getPopup();
252         if (popup == null || !popup.isShowing()) {
253             return false;
254         }
255 
256         final DropDownListView dst = (DropDownListView) popup.getListView();
257         if (dst == null || !dst.isShown()) {
258             return false;
259         }
260 
261         // Convert event to destination-local coordinates.
262         final MotionEvent dstEvent = MotionEvent.obtainNoHistory(srcEvent);
263         src.toGlobalMotionEvent(dstEvent);
264         dst.toLocalMotionEvent(dstEvent);
265 
266         // Forward converted event to destination view, then recycle it.
267         final boolean handled = dst.onForwardedEvent(dstEvent, mActivePointerId);
268         dstEvent.recycle();
269 
270         // Always cancel forwarding when the touch stream ends.
271         final int action = srcEvent.getActionMasked();
272         final boolean keepForwarding = action != MotionEvent.ACTION_UP
273                 && action != MotionEvent.ACTION_CANCEL;
274 
275         return handled && keepForwarding;
276     }
277 
278     private class DisallowIntercept implements Runnable {
279         @Override
run()280         public void run() {
281             final ViewParent parent = mSrc.getParent();
282             if (parent != null) {
283                 parent.requestDisallowInterceptTouchEvent(true);
284             }
285         }
286     }
287 
288     private class TriggerLongPress implements Runnable {
289         @Override
run()290         public void run() {
291             onLongPress();
292         }
293     }
294 }
295