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