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
17package android.widget;
18
19import android.os.SystemClock;
20import android.view.MotionEvent;
21import android.view.View;
22import android.view.ViewConfiguration;
23import android.view.ViewParent;
24
25import com.android.internal.view.menu.ShowableListMenu;
26
27/**
28 * Abstract class that forwards touch events to a {@link ShowableListMenu}.
29 *
30 * @hide
31 */
32public 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
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     */
81    public abstract ShowableListMenu getPopup();
82
83    @Override
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
107    public void onViewAttachedToWindow(View v) {
108    }
109
110    @Override
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     */
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     */
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     */
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
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
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     */
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
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
290        public void run() {
291            onLongPress();
292        }
293    }
294}
295