SlidingTab.java revision 425ca595dcc37ddb7a9f96310e5b800f424811a6
1/* 2 * Copyright (C) 2009 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 com.android.internal.widget; 18 19import android.content.Context; 20import android.content.res.Resources; 21import android.content.res.TypedArray; 22import android.graphics.Rect; 23import android.graphics.drawable.Drawable; 24import android.os.Handler; 25import android.os.Message; 26import android.os.Vibrator; 27import android.util.AttributeSet; 28import android.util.Log; 29import android.view.Gravity; 30import android.view.MotionEvent; 31import android.view.View; 32import android.view.ViewGroup; 33import android.widget.ImageView; 34import android.widget.TextView; 35import android.widget.ImageView.ScaleType; 36import com.android.internal.R; 37 38/** 39 * A special widget containing two Sliders and a threshold for each. Moving either slider beyond 40 * the threshold will cause the registered OnTriggerListener.onTrigger() to be called with 41 * whichHandle being {@link OnTriggerListener#LEFT_HANDLE} or {@link OnTriggerListener#RIGHT_HANDLE} 42 * Equivalently, selecting a tab will result in a call to 43 * {@link OnTriggerListener#onGrabbedStateChange(View, int)} with one of these two states. Releasing 44 * the tab will result in whichHandle being {@link OnTriggerListener#NO_HANDLE}. 45 * 46 */ 47public class SlidingTab extends ViewGroup { 48 private static final int ANIMATION_DURATION = 250; // animation transition duration (in ms) 49 private static final String LOG_TAG = "SlidingTab"; 50 private static final boolean DBG = false; 51 private static final int HORIZONTAL = 0; // as defined in attrs.xml 52 private static final int VERTICAL = 1; 53 private static final int MSG_ANIMATE = 100; 54 55 // TODO: Make these configurable 56 private static final float THRESHOLD = 2.0f / 3.0f; 57 private static final long VIBRATE_SHORT = 30; 58 private static final long VIBRATE_LONG = 40; 59 60 private OnTriggerListener mOnTriggerListener; 61 private int mGrabbedState = OnTriggerListener.NO_HANDLE; 62 private boolean mTriggered = false; 63 private Vibrator mVibrator; 64 private float mDensity; // used to scale dimensions for bitmaps. 65 66 private final SlidingTabHandler mHandler = new SlidingTabHandler(); 67 68 /** 69 * Either {@link #HORIZONTAL} or {@link #VERTICAL}. 70 */ 71 private int mOrientation; 72 73 private Slider mLeftSlider; 74 private Slider mRightSlider; 75 private Slider mCurrentSlider; 76 private boolean mTracking; 77 private float mThreshold; 78 private Slider mOtherSlider; 79 private boolean mAnimating; 80 81 /** 82 * Interface definition for a callback to be invoked when a tab is triggered 83 * by moving it beyond a threshold. 84 */ 85 public interface OnTriggerListener { 86 /** 87 * The interface was triggered because the user let go of the handle without reaching the 88 * threshold. 89 */ 90 public static final int NO_HANDLE = 0; 91 92 /** 93 * The interface was triggered because the user grabbed the left handle and moved it past 94 * the threshold. 95 */ 96 public static final int LEFT_HANDLE = 1; 97 98 /** 99 * The interface was triggered because the user grabbed the right handle and moved it past 100 * the threshold. 101 */ 102 public static final int RIGHT_HANDLE = 2; 103 104 /** 105 * Called when the user moves a handle beyond the threshold. 106 * 107 * @param v The view that was triggered. 108 * @param whichHandle Which "dial handle" the user grabbed, 109 * either {@link #LEFT_HANDLE}, {@link #RIGHT_HANDLE}. 110 */ 111 void onTrigger(View v, int whichHandle); 112 113 /** 114 * Called when the "grabbed state" changes (i.e. when the user either grabs or releases 115 * one of the handles.) 116 * 117 * @param v the view that was triggered 118 * @param grabbedState the new state: {@link #NO_HANDLE}, {@link #LEFT_HANDLE}, 119 * or {@link #RIGHT_HANDLE}. 120 */ 121 void onGrabbedStateChange(View v, int grabbedState); 122 } 123 124 /** 125 * Simple container class for all things pertinent to a slider. 126 * A slider consists of 3 Views: 127 * 128 * {@link #tab} is the tab shown on the screen in the default state. 129 * {@link #text} is the view revealed as the user slides the tab out. 130 * {@link #target} is the target the user must drag the slider past to trigger the slider. 131 * 132 */ 133 private static class Slider { 134 /** 135 * Tab alignment - determines which side the tab should be drawn on 136 */ 137 public static final int ALIGN_LEFT = 0; 138 public static final int ALIGN_RIGHT = 1; 139 public static final int ALIGN_TOP = 2; 140 public static final int ALIGN_BOTTOM = 3; 141 142 /** 143 * States for the view. 144 */ 145 private static final int STATE_NORMAL = 0; 146 private static final int STATE_PRESSED = 1; 147 private static final int STATE_ACTIVE = 2; 148 149 private final ImageView tab; 150 private final TextView text; 151 private final ImageView target; 152 private int currentState = STATE_NORMAL; 153 154 /** 155 * Constructor 156 * 157 * @param parent the container view of this one 158 * @param tabId drawable for the tab 159 * @param barId drawable for the bar 160 * @param targetId drawable for the target 161 */ 162 Slider(ViewGroup parent, int tabId, int barId, int targetId) { 163 // Create tab 164 tab = new ImageView(parent.getContext()); 165 tab.setBackgroundResource(tabId); 166 tab.setScaleType(ScaleType.CENTER); 167 tab.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT, 168 LayoutParams.WRAP_CONTENT)); 169 170 // Create hint TextView 171 text = new TextView(parent.getContext()); 172 text.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT, 173 LayoutParams.FILL_PARENT)); 174 text.setBackgroundResource(barId); 175 text.setTextAppearance(parent.getContext(), R.style.TextAppearance_SlidingTabNormal); 176 // hint.setSingleLine(); // Hmm.. this causes the text to disappear off-screen 177 178 // Create target 179 target = new ImageView(parent.getContext()); 180 target.setImageResource(targetId); 181 target.setScaleType(ScaleType.CENTER); 182 target.setLayoutParams( 183 new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); 184 target.setVisibility(View.INVISIBLE); 185 186 parent.addView(target); // this needs to be first - relies on painter's algorithm 187 parent.addView(tab); 188 parent.addView(text); 189 } 190 191 void setIcon(int iconId) { 192 tab.setImageResource(iconId); 193 } 194 195 void setTabBackgroundResource(int tabId) { 196 tab.setBackgroundResource(tabId); 197 } 198 199 void setBarBackgroundResource(int barId) { 200 text.setBackgroundResource(barId); 201 } 202 203 void setHintText(int resId) { 204 text.setText(resId); 205 } 206 207 void hide() { 208 // TODO: Animate off the screen 209 text.setVisibility(View.INVISIBLE); 210 tab.setVisibility(View.INVISIBLE); 211 target.setVisibility(View.INVISIBLE); 212 } 213 214 void setState(int state) { 215 text.setPressed(state == STATE_PRESSED); 216 tab.setPressed(state == STATE_PRESSED); 217 if (state == STATE_ACTIVE) { 218 final int[] activeState = new int[] {com.android.internal.R.attr.state_active}; 219 if (text.getBackground().isStateful()) { 220 text.getBackground().setState(activeState); 221 } 222 if (tab.getBackground().isStateful()) { 223 tab.getBackground().setState(activeState); 224 } 225 text.setTextAppearance(text.getContext(), R.style.TextAppearance_SlidingTabActive); 226 } else { 227 text.setTextAppearance(text.getContext(), R.style.TextAppearance_SlidingTabNormal); 228 } 229 currentState = state; 230 } 231 232 void showTarget() { 233 target.setVisibility(View.VISIBLE); 234 } 235 236 void reset() { 237 setState(STATE_NORMAL); 238 text.setVisibility(View.VISIBLE); 239 text.setTextAppearance(text.getContext(), R.style.TextAppearance_SlidingTabNormal); 240 tab.setVisibility(View.VISIBLE); 241 target.setVisibility(View.INVISIBLE); 242 } 243 244 void setTarget(int targetId) { 245 target.setImageResource(targetId); 246 } 247 248 /** 249 * Layout the given widgets within the parent. 250 * 251 * @param l the parent's left border 252 * @param t the parent's top border 253 * @param r the parent's right border 254 * @param b the parent's bottom border 255 * @param alignment which side to align the widget to 256 */ 257 void layout(int l, int t, int r, int b, int alignment) { 258 final Drawable tabBackground = tab.getBackground(); 259 final int handleWidth = tabBackground.getIntrinsicWidth(); 260 final int handleHeight = tabBackground.getIntrinsicHeight(); 261 final Drawable targetDrawable = target.getDrawable(); 262 final int targetWidth = targetDrawable.getIntrinsicWidth(); 263 final int targetHeight = targetDrawable.getIntrinsicHeight(); 264 final int parentWidth = r - l; 265 final int parentHeight = b - t; 266 267 final int leftTarget = (int) (THRESHOLD * parentWidth) - targetWidth + handleWidth / 2; 268 final int rightTarget = (int) ((1.0f - THRESHOLD) * parentWidth) - handleWidth / 2; 269 final int left = (parentWidth - handleWidth) / 2; 270 final int right = left + handleWidth; 271 272 if (alignment == ALIGN_LEFT || alignment == ALIGN_RIGHT) { 273 // horizontal 274 final int targetTop = (parentHeight - targetHeight) / 2; 275 final int targetBottom = targetTop + targetHeight; 276 final int top = (parentHeight - handleHeight) / 2; 277 final int bottom = (parentHeight + handleHeight) / 2; 278 if (alignment == ALIGN_LEFT) { 279 tab.layout(0, top, handleWidth, bottom); 280 text.layout(0 - parentWidth, top, 0, bottom); 281 text.setGravity(Gravity.RIGHT); 282 target.layout(leftTarget, targetTop, leftTarget + targetWidth, targetBottom); 283 } else { 284 tab.layout(parentWidth - handleWidth, top, parentWidth, bottom); 285 text.layout(parentWidth, top, parentWidth + parentWidth, bottom); 286 target.layout(rightTarget, targetTop, rightTarget + targetWidth, targetBottom); 287 text.setGravity(Gravity.TOP); 288 } 289 } else { 290 // vertical 291 final int targetLeft = (parentWidth - targetWidth) / 2; 292 final int targetRight = (parentWidth + targetWidth) / 2; 293 final int top = (int) (THRESHOLD * parentHeight) + handleHeight / 2 - targetHeight; 294 final int bottom = (int) ((1.0f - THRESHOLD) * parentHeight) - handleHeight / 2; 295 if (alignment == ALIGN_TOP) { 296 tab.layout(left, 0, right, handleHeight); 297 text.layout(left, 0 - parentHeight, right, 0); 298 target.layout(targetLeft, top, targetRight, top + targetHeight); 299 } else { 300 tab.layout(left, parentHeight - handleHeight, right, parentHeight); 301 text.layout(left, parentHeight, right, parentHeight + parentHeight); 302 target.layout(targetLeft, bottom, targetRight, bottom + targetHeight); 303 } 304 } 305 } 306 307 public void updateDrawableStates() { 308 setState(currentState); 309 } 310 311 /** 312 * Ensure all the dependent widgets are measured. 313 */ 314 public void measure() { 315 tab.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), 316 View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); 317 text.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), 318 View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); 319 } 320 321 /** 322 * Get the measured tab width. Must be called after {@link Slider#measure()}. 323 * @return 324 */ 325 public int getTabWidth() { 326 return tab.getMeasuredWidth(); 327 } 328 329 /** 330 * Get the measured tab width. Must be called after {@link Slider#measure()}. 331 * @return 332 */ 333 public int getTabHeight() { 334 return tab.getMeasuredHeight(); 335 } 336 } 337 338 public SlidingTab(Context context) { 339 this(context, null); 340 } 341 342 /** 343 * Constructor used when this widget is created from a layout file. 344 */ 345 public SlidingTab(Context context, AttributeSet attrs) { 346 super(context, attrs); 347 348 TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SlidingTab); 349 mOrientation = a.getInt(R.styleable.SlidingTab_orientation, HORIZONTAL); 350 a.recycle(); 351 352 Resources r = getResources(); 353 mDensity = r.getDisplayMetrics().density; 354 if (DBG) log("- Density: " + mDensity); 355 356 mLeftSlider = new Slider(this, 357 R.drawable.jog_tab_left_generic, 358 R.drawable.jog_tab_bar_left_generic, 359 R.drawable.jog_tab_target_gray); 360 mRightSlider = new Slider(this, 361 R.drawable.jog_tab_right_generic, 362 R.drawable.jog_tab_bar_right_generic, 363 R.drawable.jog_tab_target_gray); 364 365 // setBackgroundColor(0x80808080); 366 } 367 368 @Override 369 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 370 int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); 371 int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); 372 373 int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); 374 int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); 375 376 if (widthSpecMode == MeasureSpec.UNSPECIFIED || heightSpecMode == MeasureSpec.UNSPECIFIED) { 377 throw new RuntimeException(LOG_TAG + " cannot have UNSPECIFIED dimensions"); 378 } 379 380 mLeftSlider.measure(); 381 mRightSlider.measure(); 382 final int leftTabWidth = mLeftSlider.getTabWidth(); 383 final int rightTabWidth = mRightSlider.getTabWidth(); 384 final int leftTabHeight = mLeftSlider.getTabHeight(); 385 final int rightTabHeight = mRightSlider.getTabHeight(); 386 final int width; 387 final int height; 388 if (isHorizontal()) { 389 width = Math.max(widthSpecSize, leftTabWidth + rightTabWidth); 390 height = Math.max(leftTabHeight, rightTabHeight); 391 } else { 392 width = Math.max(leftTabWidth, rightTabHeight); 393 height = Math.max(heightSpecSize, leftTabHeight + rightTabHeight); 394 } 395 setMeasuredDimension(width, height); 396 } 397 398 @Override 399 public boolean onInterceptTouchEvent(MotionEvent event) { 400 final int action = event.getAction(); 401 final float x = event.getX(); 402 final float y = event.getY(); 403 404 final Rect frame = new Rect(); 405 406 if (mAnimating) { 407 return false; 408 } 409 410 View leftHandle = mLeftSlider.tab; 411 leftHandle.getHitRect(frame); 412 boolean leftHit = frame.contains((int) x, (int) y); 413 414 View rightHandle = mRightSlider.tab; 415 rightHandle.getHitRect(frame); 416 boolean rightHit = frame.contains((int)x, (int) y); 417 418 if (!mTracking && !(leftHit || rightHit)) { 419 return false; 420 } 421 422 switch (action) { 423 case MotionEvent.ACTION_DOWN: { 424 mTracking = true; 425 mTriggered = false; 426 vibrate(VIBRATE_SHORT); 427 if (leftHit) { 428 mCurrentSlider = mLeftSlider; 429 mOtherSlider = mRightSlider; 430 mThreshold = isHorizontal() ? THRESHOLD : 1.0f - THRESHOLD; 431 setGrabbedState(OnTriggerListener.LEFT_HANDLE); 432 } else { 433 mCurrentSlider = mRightSlider; 434 mOtherSlider = mLeftSlider; 435 mThreshold = isHorizontal() ? 1.0f - THRESHOLD : THRESHOLD; 436 setGrabbedState(OnTriggerListener.RIGHT_HANDLE); 437 } 438 mCurrentSlider.setState(Slider.STATE_PRESSED); 439 mCurrentSlider.showTarget(); 440 mOtherSlider.hide(); 441 break; 442 } 443 } 444 445 return true; 446 } 447 448 @Override 449 public boolean onTouchEvent(MotionEvent event) { 450 if (mTracking) { 451 final int action = event.getAction(); 452 final float x = event.getX(); 453 final float y = event.getY(); 454 final View handle = mCurrentSlider.tab; 455 switch (action) { 456 case MotionEvent.ACTION_MOVE: 457 moveHandle(x, y); 458 float position = isHorizontal() ? x : y; 459 float target = mThreshold * (isHorizontal() ? getWidth() : getHeight()); 460 boolean thresholdReached; 461 if (isHorizontal()) { 462 thresholdReached = mCurrentSlider == mLeftSlider ? 463 position > target : position < target; 464 } else { 465 thresholdReached = mCurrentSlider == mLeftSlider ? 466 position < target : position > target; 467 } 468 if (!mTriggered && thresholdReached) { 469 mTriggered = true; 470 mTracking = false; 471 mCurrentSlider.setState(Slider.STATE_ACTIVE); 472 dispatchTriggerEvent(mCurrentSlider == mLeftSlider ? 473 OnTriggerListener.LEFT_HANDLE : OnTriggerListener.RIGHT_HANDLE); 474 475 // TODO: This is a place holder for the real animation. It just holds 476 // the screen for the duration of the animation for now. 477 mAnimating = true; 478 mHandler.postDelayed(new Runnable() { 479 public void run() { 480 resetView(); 481 mAnimating = false; 482 } 483 }, ANIMATION_DURATION); 484 } 485 486 if (isHorizontal() && (y <= handle.getBottom() && y >= handle.getTop()) || 487 !isHorizontal() && (x >= handle.getLeft() && x <= handle.getRight()) ) { 488 break; 489 } 490 // Intentionally fall through - we're outside tracking rectangle 491 492 case MotionEvent.ACTION_UP: 493 case MotionEvent.ACTION_CANCEL: 494 mTracking = false; 495 mTriggered = false; 496 resetView(); 497 setGrabbedState(OnTriggerListener.NO_HANDLE); 498 break; 499 } 500 } 501 502 return mTracking || super.onTouchEvent(event); 503 } 504 505 private boolean isHorizontal() { 506 return mOrientation == HORIZONTAL; 507 } 508 509 private void resetView() { 510 mLeftSlider.reset(); 511 mRightSlider.reset(); 512 onLayout(true, getLeft(), getTop(), getLeft() + getWidth(), getTop() + getHeight()); 513 } 514 515 @Override 516 protected void onLayout(boolean changed, int l, int t, int r, int b) { 517 if (!changed) return; 518 519 // Center the widgets in the view 520 mLeftSlider.layout(l, t, r, b, isHorizontal() ? Slider.ALIGN_LEFT : Slider.ALIGN_BOTTOM); 521 mRightSlider.layout(l, t, r, b, isHorizontal() ? Slider.ALIGN_RIGHT : Slider.ALIGN_TOP); 522 523 invalidate(); // TODO: be more conservative about what we're invalidating 524 } 525 526 private void moveHandle(float x, float y) { 527 final View handle = mCurrentSlider.tab; 528 final View content = mCurrentSlider.text; 529 if (isHorizontal()) { 530 int deltaX = (int) x - handle.getLeft() - (handle.getWidth() / 2); 531 handle.offsetLeftAndRight(deltaX); 532 content.offsetLeftAndRight(deltaX); 533 } else { 534 int deltaY = (int) y - handle.getTop() - (handle.getHeight() / 2); 535 handle.offsetTopAndBottom(deltaY); 536 content.offsetTopAndBottom(deltaY); 537 } 538 invalidate(); // TODO: be more conservative about what we're invalidating 539 } 540 541 /** 542 * Sets the left handle icon to a given resource. 543 * 544 * The resource should refer to a Drawable object, or use 0 to remove 545 * the icon. 546 * 547 * @param iconId the resource ID of the icon drawable 548 * @param targetId the resource of the target drawable 549 * @param barId the resource of the bar drawable (stateful) 550 * @param tabId the resource of the 551 */ 552 public void setLeftTabResources(int iconId, int targetId, int barId, int tabId) { 553 mLeftSlider.setIcon(iconId); 554 mLeftSlider.setTarget(targetId); 555 mLeftSlider.setBarBackgroundResource(barId); 556 mLeftSlider.setTabBackgroundResource(tabId); 557 mLeftSlider.updateDrawableStates(); 558 } 559 560 /** 561 * Sets the left handle hint text to a given resource string. 562 * 563 * @param resId 564 */ 565 public void setLeftHintText(int resId) { 566 if (isHorizontal()) { 567 mLeftSlider.setHintText(resId); 568 } 569 } 570 571 /** 572 * Sets the right handle icon to a given resource. 573 * 574 * The resource should refer to a Drawable object, or use 0 to remove 575 * the icon. 576 * 577 * @param iconId the resource ID of the icon drawable 578 * @param targetId the resource of the target drawable 579 * @param barId the resource of the bar drawable (stateful) 580 * @param tabId the resource of the 581 */ 582 public void setRightTabResources(int iconId, int targetId, int barId, int tabId) { 583 mRightSlider.setIcon(iconId); 584 mRightSlider.setTarget(targetId); 585 mRightSlider.setBarBackgroundResource(barId); 586 mRightSlider.setTabBackgroundResource(tabId); 587 mRightSlider.updateDrawableStates(); 588 } 589 590 /** 591 * Sets the left handle hint text to a given resource string. 592 * 593 * @param resId 594 */ 595 public void setRightHintText(int resId) { 596 if (isHorizontal()) { 597 mRightSlider.setHintText(resId); 598 } 599 } 600 601 /** 602 * Triggers haptic feedback. 603 */ 604 private synchronized void vibrate(long duration) { 605 if (mVibrator == null) { 606 mVibrator = (android.os.Vibrator) 607 getContext().getSystemService(Context.VIBRATOR_SERVICE); 608 } 609 mVibrator.vibrate(duration); 610 } 611 612 /** 613 * Registers a callback to be invoked when the user triggers an event. 614 * 615 * @param listener the OnDialTriggerListener to attach to this view 616 */ 617 public void setOnTriggerListener(OnTriggerListener listener) { 618 mOnTriggerListener = listener; 619 } 620 621 /** 622 * Dispatches a trigger event to listener. Ignored if a listener is not set. 623 * @param whichHandle the handle that triggered the event. 624 */ 625 private void dispatchTriggerEvent(int whichHandle) { 626 vibrate(VIBRATE_LONG); 627 if (mOnTriggerListener != null) { 628 mOnTriggerListener.onTrigger(this, whichHandle); 629 } 630 } 631 632 /** 633 * Sets the current grabbed state, and dispatches a grabbed state change 634 * event to our listener. 635 */ 636 private void setGrabbedState(int newState) { 637 if (newState != mGrabbedState) { 638 mGrabbedState = newState; 639 if (mOnTriggerListener != null) { 640 mOnTriggerListener.onGrabbedStateChange(this, mGrabbedState); 641 } 642 } 643 } 644 645 private class SlidingTabHandler extends Handler { 646 public void handleMessage(Message m) { 647 switch (m.what) { 648 case MSG_ANIMATE: 649 doAnimation(); 650 break; 651 } 652 } 653 } 654 655 private void doAnimation() { 656 if (mAnimating) { 657 658 } 659 } 660 661 private void log(String msg) { 662 Log.d(LOG_TAG, msg); 663 } 664} 665