Home | History | Annotate | Download | only in widget
      1 /*
      2  * Copyright (C) 2017 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 androidx.appcompat.widget;
     18 
     19 import static android.view.View.SYSTEM_UI_FLAG_LOW_PROFILE;
     20 
     21 import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
     22 
     23 import android.content.Context;
     24 import android.text.TextUtils;
     25 import android.util.Log;
     26 import android.view.MotionEvent;
     27 import android.view.View;
     28 import android.view.ViewConfiguration;
     29 import android.view.accessibility.AccessibilityManager;
     30 
     31 import androidx.annotation.RestrictTo;
     32 import androidx.core.view.ViewCompat;
     33 import androidx.core.view.ViewConfigurationCompat;
     34 
     35 /**
     36  * Event handler used used to emulate the behavior of {@link View#setTooltipText(CharSequence)}
     37  * prior to API level 26.
     38  *
     39  * @hide
     40  */
     41 @RestrictTo(LIBRARY_GROUP)
     42 class TooltipCompatHandler implements View.OnLongClickListener, View.OnHoverListener,
     43         View.OnAttachStateChangeListener {
     44     private static final String TAG = "TooltipCompatHandler";
     45 
     46     private static final long LONG_CLICK_HIDE_TIMEOUT_MS = 2500;
     47     private static final long HOVER_HIDE_TIMEOUT_MS = 15000;
     48     private static final long HOVER_HIDE_TIMEOUT_SHORT_MS = 3000;
     49 
     50     private final View mAnchor;
     51     private final CharSequence mTooltipText;
     52     private final int mHoverSlop;
     53 
     54     private final Runnable mShowRunnable = new Runnable() {
     55         @Override
     56         public void run() {
     57             show(false /* not from touch*/);
     58         }
     59     };
     60     private final Runnable mHideRunnable = new Runnable() {
     61         @Override
     62         public void run() {
     63             hide();
     64         }
     65     };
     66 
     67     private int mAnchorX;
     68     private int mAnchorY;
     69 
     70     private TooltipPopup mPopup;
     71     private boolean mFromTouch;
     72 
     73     // The handler currently scheduled to show a tooltip, triggered by a hover
     74     // (there can be only one).
     75     private static TooltipCompatHandler sPendingHandler;
     76 
     77     // The handler currently showing a tooltip (there can be only one).
     78     private static TooltipCompatHandler sActiveHandler;
     79 
     80     /**
     81      * Set the tooltip text for the view.
     82      *
     83      * @param view        view to set the tooltip on
     84      * @param tooltipText the tooltip text
     85      */
     86     public static void setTooltipText(View view, CharSequence tooltipText) {
     87         // The code below is not attempting to update the tooltip text
     88         // for a pending or currently active tooltip, because it may lead
     89         // to updating the wrong tooltip in in some rare cases (e.g. when
     90         // action menu item views are recycled). Instead, the tooltip is
     91         // canceled/hidden. This might still be the wrong tooltip,
     92         // but hiding a wrong tooltip is less disruptive UX.
     93         if (sPendingHandler != null && sPendingHandler.mAnchor == view) {
     94             setPendingHandler(null);
     95         }
     96         if (TextUtils.isEmpty(tooltipText)) {
     97             if (sActiveHandler != null && sActiveHandler.mAnchor == view) {
     98                 sActiveHandler.hide();
     99             }
    100             view.setOnLongClickListener(null);
    101             view.setLongClickable(false);
    102             view.setOnHoverListener(null);
    103         } else {
    104             new TooltipCompatHandler(view, tooltipText);
    105         }
    106     }
    107 
    108     private TooltipCompatHandler(View anchor, CharSequence tooltipText) {
    109         mAnchor = anchor;
    110         mTooltipText = tooltipText;
    111         mHoverSlop = ViewConfigurationCompat.getScaledHoverSlop(
    112                 ViewConfiguration.get(mAnchor.getContext()));
    113         clearAnchorPos();
    114 
    115         mAnchor.setOnLongClickListener(this);
    116         mAnchor.setOnHoverListener(this);
    117     }
    118 
    119     @Override
    120     public boolean onLongClick(View v) {
    121         mAnchorX = v.getWidth() / 2;
    122         mAnchorY = v.getHeight() / 2;
    123         show(true /* from touch */);
    124         return true;
    125     }
    126 
    127     @Override
    128     public boolean onHover(View v, MotionEvent event) {
    129         if (mPopup != null && mFromTouch) {
    130             return false;
    131         }
    132         AccessibilityManager manager = (AccessibilityManager)
    133                 mAnchor.getContext().getSystemService(Context.ACCESSIBILITY_SERVICE);
    134         if (manager.isEnabled() && manager.isTouchExplorationEnabled()) {
    135             return false;
    136         }
    137         switch (event.getAction()) {
    138             case MotionEvent.ACTION_HOVER_MOVE:
    139                 if (mAnchor.isEnabled() && mPopup == null && updateAnchorPos(event)) {
    140                     setPendingHandler(this);
    141                 }
    142                 break;
    143             case MotionEvent.ACTION_HOVER_EXIT:
    144                 clearAnchorPos();
    145                 hide();
    146                 break;
    147         }
    148 
    149         return false;
    150     }
    151 
    152     @Override
    153     public void onViewAttachedToWindow(View v) {
    154         // no-op.
    155     }
    156 
    157     @Override
    158     public void onViewDetachedFromWindow(View v) {
    159         hide();
    160     }
    161 
    162     private void show(boolean fromTouch) {
    163         if (!ViewCompat.isAttachedToWindow(mAnchor)) {
    164             return;
    165         }
    166         setPendingHandler(null);
    167         if (sActiveHandler != null) {
    168             sActiveHandler.hide();
    169         }
    170         sActiveHandler = this;
    171 
    172         mFromTouch = fromTouch;
    173         mPopup = new TooltipPopup(mAnchor.getContext());
    174         mPopup.show(mAnchor, mAnchorX, mAnchorY, mFromTouch, mTooltipText);
    175         // Only listen for attach state change while the popup is being shown.
    176         mAnchor.addOnAttachStateChangeListener(this);
    177 
    178         final long timeout;
    179         if (mFromTouch) {
    180             timeout = LONG_CLICK_HIDE_TIMEOUT_MS;
    181         } else if ((ViewCompat.getWindowSystemUiVisibility(mAnchor)
    182                 & SYSTEM_UI_FLAG_LOW_PROFILE) == SYSTEM_UI_FLAG_LOW_PROFILE) {
    183             timeout = HOVER_HIDE_TIMEOUT_SHORT_MS - ViewConfiguration.getLongPressTimeout();
    184         } else {
    185             timeout = HOVER_HIDE_TIMEOUT_MS - ViewConfiguration.getLongPressTimeout();
    186         }
    187         mAnchor.removeCallbacks(mHideRunnable);
    188         mAnchor.postDelayed(mHideRunnable, timeout);
    189     }
    190 
    191     private void hide() {
    192         if (sActiveHandler == this) {
    193             sActiveHandler = null;
    194             if (mPopup != null) {
    195                 mPopup.hide();
    196                 mPopup = null;
    197                 clearAnchorPos();
    198                 mAnchor.removeOnAttachStateChangeListener(this);
    199             } else {
    200                 Log.e(TAG, "sActiveHandler.mPopup == null");
    201             }
    202         }
    203         if (sPendingHandler == this) {
    204             setPendingHandler(null);
    205         }
    206         mAnchor.removeCallbacks(mHideRunnable);
    207     }
    208 
    209     private static void setPendingHandler(TooltipCompatHandler handler) {
    210         if (sPendingHandler != null) {
    211             sPendingHandler.cancelPendingShow();
    212         }
    213         sPendingHandler = handler;
    214         if (sPendingHandler != null) {
    215             sPendingHandler.scheduleShow();
    216         }
    217     }
    218 
    219     private void scheduleShow() {
    220         mAnchor.postDelayed(mShowRunnable, ViewConfiguration.getLongPressTimeout());
    221     }
    222 
    223     private void cancelPendingShow() {
    224         mAnchor.removeCallbacks(mShowRunnable);
    225     }
    226 
    227     /**
    228      * Update the anchor position if it significantly (that is by at least mHoverSlope)
    229      * different from the previously stored position. Ignoring insignificant changes
    230      * filters out the jitter which is typical for such input sources as stylus.
    231      *
    232      * @return True if the position has been updated.
    233      */
    234     private boolean updateAnchorPos(MotionEvent event) {
    235         final int newAnchorX = (int) event.getX();
    236         final int newAnchorY = (int) event.getY();
    237         if (Math.abs(newAnchorX - mAnchorX) <= mHoverSlop
    238                 && Math.abs(newAnchorY - mAnchorY) <= mHoverSlop) {
    239             return false;
    240         }
    241         mAnchorX = newAnchorX;
    242         mAnchorY = newAnchorY;
    243         return true;
    244     }
    245 
    246     /**
    247      *  Clear the anchor position to ensure that the next change is considered significant.
    248      */
    249     private void clearAnchorPos() {
    250         mAnchorX = Integer.MAX_VALUE;
    251         mAnchorY = Integer.MAX_VALUE;
    252     }
    253 }
    254