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