ZoomButtonsController.java revision c39a6e0c51e182338deb8b63d07933b585134929
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 17package android.widget; 18 19import android.app.AlertDialog; 20import android.app.Dialog; 21import android.content.BroadcastReceiver; 22import android.content.ContentResolver; 23import android.content.Context; 24import android.content.Intent; 25import android.content.IntentFilter; 26import android.content.SharedPreferences; 27import android.graphics.PixelFormat; 28import android.graphics.Rect; 29import android.os.Handler; 30import android.os.Message; 31import android.os.SystemClock; 32import android.provider.Settings; 33import android.view.Gravity; 34import android.view.KeyEvent; 35import android.view.LayoutInflater; 36import android.view.MotionEvent; 37import android.view.View; 38import android.view.ViewConfiguration; 39import android.view.ViewGroup; 40import android.view.Window; 41import android.view.WindowManager; 42import android.view.View.OnClickListener; 43import android.view.WindowManager.LayoutParams; 44 45// TODO: make sure no px values exist, only dip (scale if necessary from Viewconfiguration) 46 47/** 48 * TODO: Docs 49 * 50 * If you are using this with a custom View, please call 51 * {@link #setVisible(boolean) setVisible(false)} from the 52 * {@link View#onDetachedFromWindow}. 53 * 54 * @hide 55 */ 56public class ZoomButtonsController implements View.OnTouchListener, View.OnKeyListener { 57 58 private static final String TAG = "ZoomButtonsController"; 59 60 private static final int ZOOM_CONTROLS_TIMEOUT = 61 (int) ViewConfiguration.getZoomControlsTimeout(); 62 63 // TODO: scaled to density 64 private static final int ZOOM_CONTROLS_TOUCH_PADDING = 20; 65 66 private Context mContext; 67 private WindowManager mWindowManager; 68 69 /** 70 * The view that is being zoomed by this zoom controller. 71 */ 72 private View mOwnerView; 73 74 /** 75 * The bounds of the owner view in global coordinates. This is recalculated 76 * each time the zoom controller is shown. 77 */ 78 private Rect mOwnerViewBounds = new Rect(); 79 80 /** 81 * The container that is added as a window. 82 */ 83 private FrameLayout mContainer; 84 private LayoutParams mContainerLayoutParams; 85 private int[] mContainerLocation = new int[2]; 86 87 private ZoomControls mControls; 88 89 /** 90 * The view (or null) that should receive touch events. This will get set if 91 * the touch down hits the container. It will be reset on the touch up. 92 */ 93 private View mTouchTargetView; 94 /** 95 * The {@link #mTouchTargetView}'s location in window, set on touch down. 96 */ 97 private int[] mTouchTargetLocationInWindow = new int[2]; 98 /** 99 * If the zoom controller is dismissed but the user is still in a touch 100 * interaction, we set this to true. This will ignore all touch events until 101 * up/cancel, and then set the owner's touch listener to null. 102 */ 103 private boolean mReleaseTouchListenerOnUp; 104 105 private boolean mIsSecondTapDown; 106 107 private boolean mIsVisible; 108 109 private Rect mTempRect = new Rect(); 110 111 private OnZoomListener mCallback; 112 113 /** 114 * When showing the zoom, we add the view as a new window. However, there is 115 * logic that needs to know the size of the zoom which is determined after 116 * it's laid out. Therefore, we must post this logic onto the UI thread so 117 * it will be exceuted AFTER the layout. This is the logic. 118 */ 119 private Runnable mPostedVisibleInitializer; 120 121 private IntentFilter mConfigurationChangedFilter = 122 new IntentFilter(Intent.ACTION_CONFIGURATION_CHANGED); 123 124 private BroadcastReceiver mConfigurationChangedReceiver = new BroadcastReceiver() { 125 @Override 126 public void onReceive(Context context, Intent intent) { 127 if (!mIsVisible) return; 128 129 mHandler.removeMessages(MSG_POST_CONFIGURATION_CHANGED); 130 mHandler.sendEmptyMessage(MSG_POST_CONFIGURATION_CHANGED); 131 } 132 }; 133 134 /** 135 * The setting name that tracks whether we've shown the zoom tutorial. 136 */ 137 private static final String SETTING_NAME_SHOWN_TUTORIAL = "shown_zoom_tutorial"; 138 private static Dialog sTutorialDialog; 139 140 /** When configuration changes, this is called after the UI thread is idle. */ 141 private static final int MSG_POST_CONFIGURATION_CHANGED = 2; 142 /** Used to delay the zoom controller dismissal. */ 143 private static final int MSG_DISMISS_ZOOM_CONTROLS = 3; 144 /** 145 * If setVisible(true) is called and the owner view's window token is null, 146 * we delay the setVisible(true) call until it is not null. 147 */ 148 private static final int MSG_POST_SET_VISIBLE = 4; 149 150 private Handler mHandler = new Handler() { 151 @Override 152 public void handleMessage(Message msg) { 153 switch (msg.what) { 154 case MSG_POST_CONFIGURATION_CHANGED: 155 onPostConfigurationChanged(); 156 break; 157 158 case MSG_DISMISS_ZOOM_CONTROLS: 159 setVisible(false); 160 break; 161 162 case MSG_POST_SET_VISIBLE: 163 if (mOwnerView.getWindowToken() == null) { 164 // Doh, it is still null, throw an exception 165 throw new IllegalArgumentException( 166 "Cannot make the zoom controller visible if the owner view is " + 167 "not attached to a window."); 168 } 169 setVisible(true); 170 break; 171 } 172 173 } 174 }; 175 176 public ZoomButtonsController(Context context, View ownerView) { 177 mContext = context; 178 mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); 179 mOwnerView = ownerView; 180 181 mContainer = createContainer(); 182 } 183 184 public void setZoomInEnabled(boolean enabled) { 185 mControls.setIsZoomInEnabled(enabled); 186 } 187 188 public void setZoomOutEnabled(boolean enabled) { 189 mControls.setIsZoomOutEnabled(enabled); 190 } 191 192 public void setZoomSpeed(long speed) { 193 mControls.setZoomSpeed(speed); 194 } 195 196 private FrameLayout createContainer() { 197 LayoutParams lp = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 198 lp.gravity = Gravity.BOTTOM | Gravity.CENTER; 199 lp.flags = LayoutParams.FLAG_NOT_TOUCHABLE | 200 LayoutParams.FLAG_NOT_FOCUSABLE | 201 LayoutParams.FLAG_LAYOUT_NO_LIMITS; 202 lp.height = LayoutParams.WRAP_CONTENT; 203 lp.width = LayoutParams.FILL_PARENT; 204 lp.type = LayoutParams.TYPE_APPLICATION_PANEL; 205 lp.format = PixelFormat.TRANSPARENT; 206 lp.windowAnimations = com.android.internal.R.style.Animation_ZoomButtons; 207 mContainerLayoutParams = lp; 208 209 FrameLayout container = new FrameLayout(mContext); 210 container.setLayoutParams(lp); 211 container.setMeasureAllChildren(true); 212 container.setOnKeyListener(this); 213 214 LayoutInflater inflater = (LayoutInflater) mContext 215 .getSystemService(Context.LAYOUT_INFLATER_SERVICE); 216 inflater.inflate(com.android.internal.R.layout.zoom_container, container); 217 218 mControls = (ZoomControls) container.findViewById(com.android.internal.R.id.zoomControls); 219 mControls.setOnZoomInClickListener(new OnClickListener() { 220 public void onClick(View v) { 221 dismissControlsDelayed(ZOOM_CONTROLS_TIMEOUT); 222 if (mCallback != null) mCallback.onZoom(true); 223 } 224 }); 225 mControls.setOnZoomOutClickListener(new OnClickListener() { 226 public void onClick(View v) { 227 dismissControlsDelayed(ZOOM_CONTROLS_TIMEOUT); 228 if (mCallback != null) mCallback.onZoom(false); 229 } 230 }); 231 232 return container; 233 } 234 235 public void setCallback(OnZoomListener callback) { 236 mCallback = callback; 237 } 238 239 public void setFocusable(boolean focusable) { 240 if (focusable) { 241 mContainerLayoutParams.flags &= ~LayoutParams.FLAG_NOT_FOCUSABLE; 242 } else { 243 mContainerLayoutParams.flags |= LayoutParams.FLAG_NOT_FOCUSABLE; 244 } 245 246 if (mIsVisible) { 247 mWindowManager.updateViewLayout(mContainer, mContainerLayoutParams); 248 } 249 } 250 251 public boolean isVisible() { 252 return mIsVisible; 253 } 254 255 public void setVisible(boolean visible) { 256 257 if (!useThisZoom(mContext)) return; 258 259 if (visible) { 260 if (mOwnerView.getWindowToken() == null) { 261 /* 262 * We need a window token to show ourselves, maybe the owner's 263 * window hasn't been created yet but it will have been by the 264 * time the looper is idle, so post the setVisible(true) call. 265 */ 266 if (!mHandler.hasMessages(MSG_POST_SET_VISIBLE)) { 267 mHandler.sendEmptyMessage(MSG_POST_SET_VISIBLE); 268 } 269 return; 270 } 271 272 dismissControlsDelayed(ZOOM_CONTROLS_TIMEOUT); 273 } 274 275 if (mIsVisible == visible) { 276 return; 277 } 278 mIsVisible = visible; 279 280 if (visible) { 281 if (mContainerLayoutParams.token == null) { 282 mContainerLayoutParams.token = mOwnerView.getWindowToken(); 283 } 284 285 mWindowManager.addView(mContainer, mContainerLayoutParams); 286 287 if (mPostedVisibleInitializer == null) { 288 mPostedVisibleInitializer = new Runnable() { 289 public void run() { 290 refreshPositioningVariables(); 291 292 if (mCallback != null) { 293 mCallback.onVisibilityChanged(true); 294 } 295 } 296 }; 297 } 298 299 mHandler.post(mPostedVisibleInitializer); 300 301 // Handle configuration changes when visible 302 mContext.registerReceiver(mConfigurationChangedReceiver, mConfigurationChangedFilter); 303 304 // Steal touches events from the owner 305 mOwnerView.setOnTouchListener(this); 306 mReleaseTouchListenerOnUp = false; 307 308 } else { 309 // Don't want to steal any more touches 310 if (mTouchTargetView != null) { 311 // We are still stealing the touch events for this touch 312 // sequence, so release the touch listener later 313 mReleaseTouchListenerOnUp = true; 314 } else { 315 mOwnerView.setOnTouchListener(null); 316 } 317 318 // No longer care about configuration changes 319 mContext.unregisterReceiver(mConfigurationChangedReceiver); 320 321 mWindowManager.removeView(mContainer); 322 mHandler.removeCallbacks(mPostedVisibleInitializer); 323 324 if (mCallback != null) { 325 mCallback.onVisibilityChanged(false); 326 } 327 } 328 329 } 330 331 /** 332 * TODO: docs 333 * 334 * Notes: 335 * - Please ensure you set your View to INVISIBLE not GONE when hiding it. 336 * 337 * @return TODO 338 */ 339 public ViewGroup getContainer() { 340 return mContainer; 341 } 342 343 private void dismissControlsDelayed(int delay) { 344 mHandler.removeMessages(MSG_DISMISS_ZOOM_CONTROLS); 345 mHandler.sendEmptyMessageDelayed(MSG_DISMISS_ZOOM_CONTROLS, delay); 346 } 347 348 /** 349 * Should be called by the client for each event belonging to the second tap 350 * (the down, move, up, and cancel events). 351 * 352 * @param event The event belonging to the second tap. 353 * @return Whether the event was consumed. 354 */ 355 public boolean handleDoubleTapEvent(MotionEvent event) { 356 if (!useThisZoom(mContext)) return false; 357 358 int action = event.getAction(); 359 360 if (action == MotionEvent.ACTION_DOWN) { 361 int x = (int) event.getX(); 362 int y = (int) event.getY(); 363 364 /* 365 * This class will consume all events in the second tap (down, 366 * move(s), up). But, the owner already got the second tap's down, 367 * so cancel that. Do this before setVisible, since that call 368 * will set us as a touch listener. 369 */ 370 MotionEvent cancelEvent = MotionEvent.obtain(event.getDownTime(), 371 SystemClock.elapsedRealtime(), 372 MotionEvent.ACTION_CANCEL, 0, 0, 0); 373 mOwnerView.dispatchTouchEvent(cancelEvent); 374 cancelEvent.recycle(); 375 376 setVisible(true); 377 centerPoint(x, y); 378 mIsSecondTapDown = true; 379 } 380 381 return true; 382 } 383 384 private void refreshPositioningVariables() { 385 // Calculate the owner view's bounds 386 mOwnerView.getGlobalVisibleRect(mOwnerViewBounds); 387 mContainer.getLocationOnScreen(mContainerLocation); 388 } 389 390 /** 391 * Centers the point (in owner view's coordinates). 392 */ 393 private void centerPoint(int x, int y) { 394 if (mCallback != null) { 395 mCallback.onCenter(x, y); 396 } 397 } 398 399 public boolean onKey(View v, int keyCode, KeyEvent event) { 400 dismissControlsDelayed(ZOOM_CONTROLS_TIMEOUT); 401 return false; 402 } 403 404 public boolean onTouch(View v, MotionEvent event) { 405 int action = event.getAction(); 406 407 // Consume all events during the second-tap interaction (down, move, up/cancel) 408 boolean consumeEvent = mIsSecondTapDown; 409 if ((action == MotionEvent.ACTION_UP) || (action == MotionEvent.ACTION_CANCEL)) { 410 // The second tap can no longer be down 411 mIsSecondTapDown = false; 412 } 413 414 if (mReleaseTouchListenerOnUp) { 415 // The controls were dismissed but we need to throw away all events until the up 416 if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { 417 mOwnerView.setOnTouchListener(null); 418 setTouchTargetView(null); 419 mReleaseTouchListenerOnUp = false; 420 } 421 422 // Eat this event 423 return true; 424 } 425 426 // TODO: optimize this (it ends up removing message and queuing another) 427 dismissControlsDelayed(ZOOM_CONTROLS_TIMEOUT); 428 429 View targetView = mTouchTargetView; 430 431 switch (action) { 432 case MotionEvent.ACTION_DOWN: 433 targetView = getViewForTouch((int) event.getRawX(), (int) event.getRawY()); 434 setTouchTargetView(targetView); 435 break; 436 437 case MotionEvent.ACTION_UP: 438 case MotionEvent.ACTION_CANCEL: 439 setTouchTargetView(null); 440 break; 441 } 442 443 if (targetView != null) { 444 // The upperleft corner of the target view in raw coordinates 445 int targetViewRawX = mContainerLocation[0] + mTouchTargetLocationInWindow[0]; 446 int targetViewRawY = mContainerLocation[1] + mTouchTargetLocationInWindow[1]; 447 448 MotionEvent containerEvent = MotionEvent.obtain(event); 449 // Convert the motion event into the target view's coordinates (from 450 // owner view's coordinates) 451 containerEvent.offsetLocation(mOwnerViewBounds.left - targetViewRawX, 452 mOwnerViewBounds.top - targetViewRawY); 453 boolean retValue = targetView.dispatchTouchEvent(containerEvent); 454 containerEvent.recycle(); 455 return retValue || consumeEvent; 456 457 } else { 458 return consumeEvent; 459 } 460 } 461 462 private void setTouchTargetView(View view) { 463 mTouchTargetView = view; 464 if (view != null) { 465 view.getLocationInWindow(mTouchTargetLocationInWindow); 466 } 467 } 468 469 /** 470 * Returns the View that should receive a touch at the given coordinates. 471 * 472 * @param rawX The raw X. 473 * @param rawY The raw Y. 474 * @return The view that should receive the touches, or null if there is not one. 475 */ 476 private View getViewForTouch(int rawX, int rawY) { 477 // Reverse order so the child drawn on top gets first dibs. 478 int containerCoordsX = rawX - mContainerLocation[0]; 479 int containerCoordsY = rawY - mContainerLocation[1]; 480 Rect frame = mTempRect; 481 for (int i = mContainer.getChildCount() - 1; i >= 0; i--) { 482 View child = mContainer.getChildAt(i); 483 if (child.getVisibility() != View.VISIBLE) { 484 continue; 485 } 486 487 child.getHitRect(frame); 488 // Expand the touch region 489 frame.top -= ZOOM_CONTROLS_TOUCH_PADDING; 490 if (frame.contains(containerCoordsX, containerCoordsY)) { 491 return child; 492 } 493 } 494 495 return null; 496 } 497 498 private void onPostConfigurationChanged() { 499 dismissControlsDelayed(ZOOM_CONTROLS_TIMEOUT); 500 refreshPositioningVariables(); 501 } 502 503 /* 504 * This is static so Activities can call this instead of the Views 505 * (Activities usually do not have a reference to the ZoomButtonsController 506 * instance.) 507 */ 508 /** 509 * Shows a "tutorial" (some text) to the user teaching her the new zoom 510 * invocation method. Must call from the main thread. 511 * <p> 512 * It checks the global system setting to ensure this has not been seen 513 * before. Furthermore, if the application does not have privilege to write 514 * to the system settings, it will store this bit locally in a shared 515 * preference. 516 * 517 * @hide This should only be used by our main apps--browser, maps, and 518 * gallery 519 */ 520 public static void showZoomTutorialOnce(Context context) { 521 ContentResolver cr = context.getContentResolver(); 522 if (Settings.System.getInt(cr, SETTING_NAME_SHOWN_TUTORIAL, 0) == 1) { 523 return; 524 } 525 526 SharedPreferences sp = context.getSharedPreferences("_zoom", Context.MODE_PRIVATE); 527 if (sp.getInt(SETTING_NAME_SHOWN_TUTORIAL, 0) == 1) { 528 return; 529 } 530 531 if (sTutorialDialog != null && sTutorialDialog.isShowing()) { 532 sTutorialDialog.dismiss(); 533 } 534 535 LayoutInflater layoutInflater = 536 (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); 537 TextView textView = (TextView) layoutInflater.inflate( 538 com.android.internal.R.layout.alert_dialog_simple_text, null) 539 .findViewById(android.R.id.text1); 540 textView.setText(com.android.internal.R.string.tutorial_double_tap_to_zoom_message_short); 541 542 sTutorialDialog = new AlertDialog.Builder(context) 543 .setView(textView) 544 .setIcon(0) 545 .create(); 546 547 Window window = sTutorialDialog.getWindow(); 548 window.setGravity(Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM); 549 window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND | 550 WindowManager.LayoutParams.FLAG_BLUR_BEHIND); 551 window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | 552 WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE); 553 554 sTutorialDialog.show(); 555 } 556 557 /** @hide Should only be used by Android platform apps */ 558 public static void finishZoomTutorial(Context context, boolean userNotified) { 559 if (sTutorialDialog == null) return; 560 561 sTutorialDialog.dismiss(); 562 sTutorialDialog = null; 563 564 // Record that they have seen the tutorial 565 if (userNotified) { 566 try { 567 Settings.System.putInt(context.getContentResolver(), SETTING_NAME_SHOWN_TUTORIAL, 568 1); 569 } catch (SecurityException e) { 570 /* 571 * The app does not have permission to clear this global flag, make 572 * sure the user does not see the message when he comes back to this 573 * same app at least. 574 */ 575 SharedPreferences sp = context.getSharedPreferences("_zoom", Context.MODE_PRIVATE); 576 sp.edit().putInt(SETTING_NAME_SHOWN_TUTORIAL, 1).commit(); 577 } 578 } 579 } 580 581 /** @hide Should only be used by Android platform apps */ 582 public void finishZoomTutorial() { 583 finishZoomTutorial(mContext, true); 584 } 585 586 // Temporary methods for different zoom types 587 static int getZoomType(Context context) { 588 return Settings.System.getInt(context.getContentResolver(), "zoom", 2); 589 } 590 591 public static boolean useOldZoom(Context context) { 592 return getZoomType(context) == 0; 593 } 594 595 public static boolean useThisZoom(Context context) { 596 return getZoomType(context) == 2; 597 } 598 599 public interface OnZoomListener { 600 void onCenter(int x, int y); 601 void onVisibilityChanged(boolean visible); 602 void onZoom(boolean zoomIn); 603 } 604} 605