Home | History | Annotate | Download | only in view
      1 /*
      2  * Copyright (C) 2008 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 
     17 package android.view;
     18 
     19 import android.annotation.NonNull;
     20 import android.annotation.UnsupportedAppUsage;
     21 import android.graphics.Rect;
     22 import android.graphics.Region;
     23 import android.util.ArrayMap;
     24 import android.view.accessibility.AccessibilityNodeInfo.TouchDelegateInfo;
     25 
     26 /**
     27  * Helper class to handle situations where you want a view to have a larger touch area than its
     28  * actual view bounds. The view whose touch area is changed is called the delegate view. This
     29  * class should be used by an ancestor of the delegate. To use a TouchDelegate, first create an
     30  * instance that specifies the bounds that should be mapped to the delegate and the delegate
     31  * view itself.
     32  * <p>
     33  * The ancestor should then forward all of its touch events received in its
     34  * {@link android.view.View#onTouchEvent(MotionEvent)} to {@link #onTouchEvent(MotionEvent)}.
     35  * </p>
     36  */
     37 public class TouchDelegate {
     38 
     39     /**
     40      * View that should receive forwarded touch events
     41      */
     42     private View mDelegateView;
     43 
     44     /**
     45      * Bounds in local coordinates of the containing view that should be mapped to the delegate
     46      * view. This rect is used for initial hit testing.
     47      */
     48     private Rect mBounds;
     49 
     50     /**
     51      * mBounds inflated to include some slop. This rect is to track whether the motion events
     52      * should be considered to be within the delegate view.
     53      */
     54     private Rect mSlopBounds;
     55 
     56     /**
     57      * True if the delegate had been targeted on a down event (intersected mBounds).
     58      */
     59     @UnsupportedAppUsage
     60     private boolean mDelegateTargeted;
     61 
     62     /**
     63      * The touchable region of the View extends above its actual extent.
     64      */
     65     public static final int ABOVE = 1;
     66 
     67     /**
     68      * The touchable region of the View extends below its actual extent.
     69      */
     70     public static final int BELOW = 2;
     71 
     72     /**
     73      * The touchable region of the View extends to the left of its actual extent.
     74      */
     75     public static final int TO_LEFT = 4;
     76 
     77     /**
     78      * The touchable region of the View extends to the right of its actual extent.
     79      */
     80     public static final int TO_RIGHT = 8;
     81 
     82     private int mSlop;
     83 
     84     /**
     85      * Touch delegate information for accessibility
     86      */
     87     private TouchDelegateInfo mTouchDelegateInfo;
     88 
     89     /**
     90      * Constructor
     91      *
     92      * @param bounds Bounds in local coordinates of the containing view that should be mapped to
     93      *        the delegate view
     94      * @param delegateView The view that should receive motion events
     95      */
     96     public TouchDelegate(Rect bounds, View delegateView) {
     97         mBounds = bounds;
     98 
     99         mSlop = ViewConfiguration.get(delegateView.getContext()).getScaledTouchSlop();
    100         mSlopBounds = new Rect(bounds);
    101         mSlopBounds.inset(-mSlop, -mSlop);
    102         mDelegateView = delegateView;
    103     }
    104 
    105     /**
    106      * Forward touch events to the delegate view if the event is within the bounds
    107      * specified in the constructor.
    108      *
    109      * @param event The touch event to forward
    110      * @return True if the event was consumed by the delegate, false otherwise.
    111      */
    112     public boolean onTouchEvent(@NonNull MotionEvent event) {
    113         int x = (int)event.getX();
    114         int y = (int)event.getY();
    115         boolean sendToDelegate = false;
    116         boolean hit = true;
    117         boolean handled = false;
    118 
    119         switch (event.getActionMasked()) {
    120             case MotionEvent.ACTION_DOWN:
    121                 mDelegateTargeted = mBounds.contains(x, y);
    122                 sendToDelegate = mDelegateTargeted;
    123                 break;
    124             case MotionEvent.ACTION_POINTER_DOWN:
    125             case MotionEvent.ACTION_POINTER_UP:
    126             case MotionEvent.ACTION_UP:
    127             case MotionEvent.ACTION_MOVE:
    128                 sendToDelegate = mDelegateTargeted;
    129                 if (sendToDelegate) {
    130                     Rect slopBounds = mSlopBounds;
    131                     if (!slopBounds.contains(x, y)) {
    132                         hit = false;
    133                     }
    134                 }
    135                 break;
    136             case MotionEvent.ACTION_CANCEL:
    137                 sendToDelegate = mDelegateTargeted;
    138                 mDelegateTargeted = false;
    139                 break;
    140         }
    141         if (sendToDelegate) {
    142             if (hit) {
    143                 // Offset event coordinates to be inside the target view
    144                 event.setLocation(mDelegateView.getWidth() / 2, mDelegateView.getHeight() / 2);
    145             } else {
    146                 // Offset event coordinates to be outside the target view (in case it does
    147                 // something like tracking pressed state)
    148                 int slop = mSlop;
    149                 event.setLocation(-(slop * 2), -(slop * 2));
    150             }
    151             handled = mDelegateView.dispatchTouchEvent(event);
    152         }
    153         return handled;
    154     }
    155 
    156     /**
    157      * Forward hover events to the delegate view if the event is within the bounds
    158      * specified in the constructor and touch exploration is enabled.
    159      *
    160      * <p>This method is provided for accessibility purposes so touch exploration, which is
    161      * commonly used by screen readers, can properly place accessibility focus on views that
    162      * use touch delegates. Therefore, touch exploration must be enabled for hover events
    163      * to be dispatched through the delegate.</p>
    164      *
    165      * @param event The hover event to forward
    166      * @return True if the event was consumed by the delegate, false otherwise.
    167      *
    168      * @see android.view.accessibility.AccessibilityManager#isTouchExplorationEnabled
    169      */
    170     public boolean onTouchExplorationHoverEvent(@NonNull MotionEvent event) {
    171         if (mBounds == null) {
    172             return false;
    173         }
    174 
    175         final int x = (int) event.getX();
    176         final int y = (int) event.getY();
    177         boolean hit = true;
    178         boolean handled = false;
    179 
    180         final boolean isInbound = mBounds.contains(x, y);
    181         switch (event.getActionMasked()) {
    182             case MotionEvent.ACTION_HOVER_ENTER:
    183                 mDelegateTargeted = isInbound;
    184                 break;
    185             case MotionEvent.ACTION_HOVER_MOVE:
    186                 if (isInbound) {
    187                     mDelegateTargeted = true;
    188                 } else {
    189                     // delegated previously
    190                     if (mDelegateTargeted && !mSlopBounds.contains(x, y)) {
    191                         hit = false;
    192                     }
    193                 }
    194                 break;
    195             case MotionEvent.ACTION_HOVER_EXIT:
    196                 mDelegateTargeted = true;
    197                 break;
    198         }
    199         if (mDelegateTargeted) {
    200             if (hit) {
    201                 event.setLocation(mDelegateView.getWidth() / 2, mDelegateView.getHeight() / 2);
    202             } else {
    203                 mDelegateTargeted = false;
    204             }
    205             handled = mDelegateView.dispatchHoverEvent(event);
    206         }
    207         return handled;
    208     }
    209 
    210     /**
    211      * Return a {@link TouchDelegateInfo} mapping from regions (in view coordinates) to
    212      * delegated views for accessibility usage.
    213      *
    214      * @return A TouchDelegateInfo.
    215      */
    216     @NonNull
    217     public TouchDelegateInfo getTouchDelegateInfo() {
    218         if (mTouchDelegateInfo == null) {
    219             final ArrayMap<Region, View> targetMap = new ArrayMap<>(1);
    220             Rect bounds = mBounds;
    221             if (bounds == null) {
    222                 bounds = new Rect();
    223             }
    224             targetMap.put(new Region(bounds), mDelegateView);
    225             mTouchDelegateInfo = new TouchDelegateInfo(targetMap);
    226         }
    227         return mTouchDelegateInfo;
    228     }
    229 }
    230