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