1/*
2 * Copyright (C) 2013 Google Inc.
3 * Licensed to The Android Open Source Project.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 *      http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18package com.android.mail.browse;
19
20import android.content.Context;
21import android.util.AttributeSet;
22import android.view.GestureDetector;
23import android.view.MotionEvent;
24import android.view.ScaleGestureDetector;
25import android.view.ViewConfiguration;
26import android.widget.ScrollView;
27
28import com.android.mail.utils.LogUtils;
29
30import java.util.Set;
31import java.util.concurrent.CopyOnWriteArraySet;
32
33/**
34 * A container that tries to play nice with an internally scrollable {@link Touchable} child view.
35 * The assumption is that the child view can scroll horizontally, but not vertically, so any
36 * touch events on that child view should ALSO be sent here so it can simultaneously vertically
37 * scroll (not the standard either/or behavior).
38 * <p>
39 * Touch events on any other child of this ScrollView are intercepted in the standard fashion.
40 */
41public class MessageScrollView extends ScrollView implements ScrollNotifier,
42        ScaleGestureDetector.OnScaleGestureListener, GestureDetector.OnDoubleTapListener {
43
44    /**
45     * A View that reports whether onTouchEvent() was recently called.
46     */
47    public interface Touchable {
48        boolean wasTouched();
49        void clearTouched();
50        boolean zoomIn();
51        boolean zoomOut();
52    }
53
54    /**
55     * True when performing "special" interception.
56     */
57    private boolean mWantToIntercept;
58    /**
59     * Whether to perform the standard touch interception procedure. This is set to true when we
60     * want to intercept a touch stream from any child OTHER than {@link #mTouchableChild}.
61     */
62    private boolean mInterceptNormally;
63    /**
64     * The special child that we want to NOT intercept from in the normal way. Instead, this child
65     * will continue to receive the touch event stream (so it can handle the horizontal component)
66     * while this parent will additionally handle the events to perform vertical scrolling.
67     */
68    private Touchable mTouchableChild;
69
70    /**
71     * We want to detect the scale gesture so that we don't try to scroll instead, but we don't
72     * care about actually interpreting it because the webview does that by itself when it handles
73     * the touch events.
74     *
75     * This might lead to really weird interactions if the two gesture detectors' implementations
76     * drift...
77     */
78    private ScaleGestureDetector mScaleDetector;
79    private boolean mInScaleGesture;
80
81    /**
82     * We also want to detect double-tap gestures, but in a way that doesn't conflict with
83     * tap-tap-drag gestures
84     */
85    private GestureDetector mGestureDetector;
86    private boolean mDoubleTapOccurred;
87    private boolean mZoomedIn;
88
89    /**
90     * Touch slop used to determine if this double tap is valid for starting a scale or should be
91     * ignored.
92     */
93    private int mTouchSlopSquared;
94
95    /**
96     * X and Y coordinates for the current down event. Since mDoubleTapOccurred only contains the
97     * information that there was a double tap event, use these to get the secondary tap
98     * information to determine if a user has moved beyond touch slop.
99     */
100    private float mDownFocusX;
101    private float mDownFocusY;
102
103    private final Set<ScrollListener> mScrollListeners =
104            new CopyOnWriteArraySet<ScrollListener>();
105
106    public static final String LOG_TAG = "MsgScroller";
107
108    public MessageScrollView(Context c) {
109        this(c, null);
110    }
111
112    public MessageScrollView(Context c, AttributeSet attrs) {
113        super(c, attrs);
114        final int touchSlop = ViewConfiguration.get(c).getScaledTouchSlop();
115        mTouchSlopSquared = touchSlop * touchSlop;
116        mScaleDetector = new ScaleGestureDetector(c, this);
117        mGestureDetector = new GestureDetector(c, new GestureDetector.SimpleOnGestureListener());
118        mGestureDetector.setOnDoubleTapListener(this);
119    }
120
121    public void setInnerScrollableView(Touchable child) {
122        mTouchableChild = child;
123    }
124
125    @Override
126    public boolean onInterceptTouchEvent(MotionEvent ev) {
127        if (mInterceptNormally) {
128            LogUtils.d(LOG_TAG, "IN ScrollView.onIntercept, NOW stealing. ev=%s", ev);
129            return true;
130        } else if (mWantToIntercept) {
131            LogUtils.d(LOG_TAG, "IN ScrollView.onIntercept, already stealing. ev=%s", ev);
132            return false;
133        }
134
135        mWantToIntercept = super.onInterceptTouchEvent(ev);
136        LogUtils.d(LOG_TAG, "OUT ScrollView.onIntercept, steal=%s ev=%s", mWantToIntercept, ev);
137        return false;
138    }
139
140    @Override
141    public boolean dispatchTouchEvent(MotionEvent ev) {
142        final int action = ev.getActionMasked();
143        switch (action) {
144            case MotionEvent.ACTION_DOWN:
145                LogUtils.d(LOG_TAG, "IN ScrollView.dispatchTouch, clearing flags");
146                mWantToIntercept = false;
147                mInterceptNormally = false;
148                break;
149        }
150        if (mTouchableChild != null) {
151            mTouchableChild.clearTouched();
152        }
153
154        mScaleDetector.onTouchEvent(ev);
155        mGestureDetector.onTouchEvent(ev);
156
157        final boolean handled = super.dispatchTouchEvent(ev);
158        LogUtils.d(LOG_TAG, "OUT ScrollView.dispatchTouch, handled=%s ev=%s", handled, ev);
159
160        if (mWantToIntercept && !mInScaleGesture) {
161            final boolean touchedChild = (mTouchableChild != null && mTouchableChild.wasTouched());
162            if (touchedChild) {
163                // also give the event to this scroll view if the WebView got the event
164                // and didn't stop any parent interception
165                LogUtils.d(LOG_TAG, "IN extra ScrollView.onTouch, ev=%s", ev);
166                onTouchEvent(ev);
167            } else {
168                mInterceptNormally = true;
169                mWantToIntercept = false;
170            }
171        }
172
173        return handled;
174    }
175
176    @Override
177    public boolean onScale(ScaleGestureDetector detector) {
178        return true;
179    }
180
181    @Override
182    public boolean onScaleBegin(ScaleGestureDetector detector) {
183        LogUtils.d(LOG_TAG, "Begin scale gesture");
184        mInScaleGesture = true;
185        return true;
186    }
187
188    @Override
189    public void onScaleEnd(ScaleGestureDetector detector) {
190        LogUtils.d(LOG_TAG, "End scale gesture");
191        mInScaleGesture = false;
192    }
193
194    @Override
195    public boolean onSingleTapConfirmed(MotionEvent e) {
196        return false;
197    }
198
199    @Override
200    public boolean onDoubleTap(MotionEvent e) {
201        mDoubleTapOccurred = true;
202        return false;
203    }
204
205    @Override
206    public boolean onDoubleTapEvent(MotionEvent e) {
207        final int action = e.getAction();
208        boolean handled = false;
209
210        switch (action) {
211            case MotionEvent.ACTION_DOWN:
212                mDownFocusX = e.getX();
213                mDownFocusY = e.getY();
214                break;
215            case MotionEvent.ACTION_UP:
216                handled = triggerZoom();
217                break;
218            case MotionEvent.ACTION_MOVE:
219                final int deltaX = (int) (e.getX() - mDownFocusX);
220                final int deltaY = (int) (e.getY() - mDownFocusY);
221                int distance = (deltaX * deltaX) + (deltaY * deltaY);
222                if (distance > mTouchSlopSquared) {
223                    mDoubleTapOccurred = false;
224                }
225                break;
226
227        }
228        return handled;
229    }
230
231    private boolean triggerZoom() {
232        boolean handled = false;
233        if (mDoubleTapOccurred) {
234            if (mZoomedIn) {
235                mTouchableChild.zoomOut();
236            } else {
237                mTouchableChild.zoomIn();
238            }
239            mZoomedIn = !mZoomedIn;
240            LogUtils.d(LogUtils.TAG, "Trigger Zoom!");
241            handled = true;
242        }
243        mDoubleTapOccurred = false;
244        return handled;
245    }
246
247    @Override
248    public void addScrollListener(ScrollListener l) {
249        mScrollListeners.add(l);
250    }
251
252    @Override
253    public void removeScrollListener(ScrollListener l) {
254        mScrollListeners.remove(l);
255    }
256
257    @Override
258    protected void onScrollChanged(int l, int t, int oldl, int oldt) {
259        super.onScrollChanged(l, t, oldl, oldt);
260        for (ScrollListener listener : mScrollListeners) {
261            listener.onNotifierScroll(t);
262        }
263    }
264
265    @Override
266    public int computeVerticalScrollRange() {
267        return super.computeVerticalScrollRange();
268    }
269
270    @Override
271    public int computeVerticalScrollOffset() {
272        return super.computeVerticalScrollOffset();
273    }
274
275    @Override
276    public int computeVerticalScrollExtent() {
277        return super.computeVerticalScrollExtent();
278    }
279
280    @Override
281    public int computeHorizontalScrollRange() {
282        return super.computeHorizontalScrollRange();
283    }
284
285    @Override
286    public int computeHorizontalScrollOffset() {
287        return super.computeHorizontalScrollOffset();
288    }
289
290    @Override
291    public int computeHorizontalScrollExtent() {
292        return super.computeHorizontalScrollExtent();
293    }
294}
295