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}