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