SlidingTab.java revision 4811d62d5f07a1ffbf085d5e424e2b54d0e7ebfd
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 // TODO: Text should be blank if widget is vertical 205 text.setText(resId); 206 } 207 208 void hide() { 209 // TODO: Animate off the screen 210 text.setVisibility(View.INVISIBLE); 211 tab.setVisibility(View.INVISIBLE); 212 target.setVisibility(View.INVISIBLE); 213 } 214 215 void setState(int state) { 216 text.setPressed(state == STATE_PRESSED); 217 tab.setPressed(state == STATE_PRESSED); 218 if (state == STATE_ACTIVE) { 219 final int[] activeState = new int[] {com.android.internal.R.attr.state_active}; 220 if (text.getBackground().isStateful()) { 221 text.getBackground().setState(activeState); 222 } 223 if (tab.getBackground().isStateful()) { 224 tab.getBackground().setState(activeState); 225 } 226 text.setTextAppearance(text.getContext(), R.style.TextAppearance_SlidingTabActive); 227 } else { 228 text.setTextAppearance(text.getContext(), R.style.TextAppearance_SlidingTabNormal); 229 } 230 currentState = state; 231 } 232 233 void showTarget() { 234 target.setVisibility(View.VISIBLE); 235 } 236 237 void reset() { 238 setState(STATE_NORMAL); 239 text.setVisibility(View.VISIBLE); 240 text.setTextAppearance(text.getContext(), R.style.TextAppearance_SlidingTabNormal); 241 tab.setVisibility(View.VISIBLE); 242 target.setVisibility(View.INVISIBLE); 243 } 244 245 void setTarget(int targetId) { 246 target.setImageResource(targetId); 247 } 248 249 /** 250 * Layout the given widgets within the parent. 251 * 252 * @param l the parent's left border 253 * @param t the parent's top border 254 * @param r the parent's right border 255 * @param b the parent's bottom border 256 * @param alignment which side to align the widget to 257 */ 258 void layout(int l, int t, int r, int b, int alignment) { 259 final Drawable tabBackground = tab.getBackground(); 260 final int handleWidth = tabBackground.getIntrinsicWidth(); 261 final int handleHeight = tabBackground.getIntrinsicHeight(); 262 final Drawable targetDrawable = target.getDrawable(); 263 final int targetWidth = targetDrawable.getIntrinsicWidth(); 264 final int targetHeight = targetDrawable.getIntrinsicHeight(); 265 final int parentWidth = r - l; 266 final int parentHeight = b - t; 267 268 final int leftTarget = (int) (THRESHOLD * parentWidth) - targetWidth + handleWidth / 2; 269 final int rightTarget = (int) ((1.0f - THRESHOLD) * parentWidth) - handleWidth / 2; 270 final int left = (parentWidth - handleWidth) / 2; 271 final int right = left + handleWidth; 272 273 if (alignment == ALIGN_LEFT || alignment == ALIGN_RIGHT) { 274 // horizontal 275 final int targetTop = (parentHeight - targetHeight) / 2; 276 final int targetBottom = targetTop + targetHeight; 277 final int top = (parentHeight - handleHeight) / 2; 278 final int bottom = (parentHeight + handleHeight) / 2; 279 if (alignment == ALIGN_LEFT) { 280 tab.layout(0, top, handleWidth, bottom); 281 text.layout(0 - parentWidth, top, 0, bottom); 282 text.setGravity(Gravity.RIGHT); 283 target.layout(leftTarget, targetTop, leftTarget + targetWidth, targetBottom); 284 } else { 285 tab.layout(parentWidth - handleWidth, top, parentWidth, bottom); 286 text.layout(parentWidth, top, parentWidth + parentWidth, bottom); 287 target.layout(rightTarget, targetTop, rightTarget + targetWidth, targetBottom); 288 text.setGravity(Gravity.TOP); 289 } 290 } else { 291 // vertical 292 final int targetLeft = (parentWidth - targetWidth) / 2; 293 final int targetRight = (parentWidth + targetWidth) / 2; 294 final int top = (int) (THRESHOLD * parentHeight) + handleHeight / 2 - targetHeight; 295 final int bottom = (int) ((1.0f - THRESHOLD) * parentHeight) - handleHeight / 2; 296 if (alignment == ALIGN_TOP) { 297 tab.layout(left, 0, right, handleHeight); 298 text.layout(left, 0 - parentHeight, right, 0); 299 target.layout(targetLeft, top, targetRight, top + targetHeight); 300 } else { 301 tab.layout(left, parentHeight - handleHeight, right, parentHeight); 302 text.layout(left, parentHeight, right, parentHeight + parentHeight); 303 target.layout(targetLeft, bottom, targetRight, bottom + targetHeight); 304 } 305 } 306 } 307 308 public void updateDrawableStates() { 309 setState(currentState); 310 } 311 312 /** 313 * Ensure all the dependent widgets are measured. 314 */ 315 public void measure() { 316 tab.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), 317 View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); 318 text.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), 319 View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); 320 } 321 322 /** 323 * Get the measured tab width. Must be called after {@link Slider#measure()}. 324 * @return 325 */ 326 public int getTabWidth() { 327 return tab.getMeasuredWidth(); 328 } 329 330 /** 331 * Get the measured tab width. Must be called after {@link Slider#measure()}. 332 * @return 333 */ 334 public int getTabHeight() { 335 return tab.getMeasuredHeight(); 336 } 337 } 338 339 public SlidingTab(Context context) { 340 this(context, null); 341 } 342 343 /** 344 * Constructor used when this widget is created from a layout file. 345 */ 346 public SlidingTab(Context context, AttributeSet attrs) { 347 super(context, attrs); 348 349 TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SlidingTab); 350 mOrientation = a.getInt(R.styleable.SlidingTab_orientation, HORIZONTAL); 351 a.recycle(); 352 353 Resources r = getResources(); 354 mDensity = r.getDisplayMetrics().density; 355 if (DBG) log("- Density: " + mDensity); 356 357 mLeftSlider = new Slider(this, 358 R.drawable.jog_tab_left_generic, 359 R.drawable.jog_tab_bar_left_generic, 360 R.drawable.jog_tab_target_gray); 361 mRightSlider = new Slider(this, 362 R.drawable.jog_tab_right_generic, 363 R.drawable.jog_tab_bar_right_generic, 364 R.drawable.jog_tab_target_gray); 365 366 // setBackgroundColor(0x80808080); 367 } 368 369 @Override 370 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 371 int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); 372 int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); 373 374 int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); 375 int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); 376 377 if (widthSpecMode == MeasureSpec.UNSPECIFIED || heightSpecMode == MeasureSpec.UNSPECIFIED) { 378 throw new RuntimeException(LOG_TAG + " cannot have UNSPECIFIED dimensions"); 379 } 380 381 mLeftSlider.measure(); 382 mRightSlider.measure(); 383 final int leftTabWidth = mLeftSlider.getTabWidth(); 384 final int rightTabWidth = mRightSlider.getTabWidth(); 385 final int leftTabHeight = mLeftSlider.getTabHeight(); 386 final int rightTabHeight = mRightSlider.getTabHeight(); 387 final int width; 388 final int height; 389 if (isHorizontal()) { 390 width = Math.max(widthSpecSize, leftTabWidth + rightTabWidth); 391 height = Math.max(leftTabHeight, rightTabHeight); 392 } else { 393 width = Math.max(leftTabWidth, rightTabHeight); 394 height = Math.max(heightSpecSize, leftTabHeight + rightTabHeight); 395 } 396 setMeasuredDimension(width, height); 397 } 398 399 @Override 400 public boolean onInterceptTouchEvent(MotionEvent event) { 401 final int action = event.getAction(); 402 final float x = event.getX(); 403 final float y = event.getY(); 404 405 final Rect frame = new Rect(); 406 407 if (mAnimating) { 408 return false; 409 } 410 411 View leftHandle = mLeftSlider.tab; 412 leftHandle.getHitRect(frame); 413 boolean leftHit = frame.contains((int) x, (int) y); 414 415 View rightHandle = mRightSlider.tab; 416 rightHandle.getHitRect(frame); 417 boolean rightHit = frame.contains((int)x, (int) y); 418 419 if (!mTracking && !(leftHit || rightHit)) { 420 return false; 421 } 422 423 switch (action) { 424 case MotionEvent.ACTION_DOWN: { 425 mTracking = true; 426 mTriggered = false; 427 vibrate(VIBRATE_SHORT); 428 if (leftHit) { 429 mCurrentSlider = mLeftSlider; 430 mOtherSlider = mRightSlider; 431 mThreshold = isHorizontal() ? THRESHOLD : 1.0f - THRESHOLD; 432 setGrabbedState(OnTriggerListener.LEFT_HANDLE); 433 } else { 434 mCurrentSlider = mRightSlider; 435 mOtherSlider = mLeftSlider; 436 mThreshold = isHorizontal() ? 1.0f - THRESHOLD : THRESHOLD; 437 setGrabbedState(OnTriggerListener.RIGHT_HANDLE); 438 } 439 mCurrentSlider.setState(Slider.STATE_PRESSED); 440 mCurrentSlider.showTarget(); 441 mOtherSlider.hide(); 442 break; 443 } 444 } 445 446 return true; 447 } 448 449 @Override 450 public boolean onTouchEvent(MotionEvent event) { 451 if (mTracking) { 452 final int action = event.getAction(); 453 final float x = event.getX(); 454 final float y = event.getY(); 455 final View handle = mCurrentSlider.tab; 456 switch (action) { 457 case MotionEvent.ACTION_MOVE: 458 moveHandle(x, y); 459 float position = isHorizontal() ? x : y; 460 float target = mThreshold * (isHorizontal() ? getWidth() : getHeight()); 461 boolean thresholdReached; 462 if (isHorizontal()) { 463 thresholdReached = mCurrentSlider == mLeftSlider ? 464 position > target : position < target; 465 } else { 466 thresholdReached = mCurrentSlider == mLeftSlider ? 467 position < target : position > target; 468 } 469 if (!mTriggered && thresholdReached) { 470 mTriggered = true; 471 mTracking = false; 472 mCurrentSlider.setState(Slider.STATE_ACTIVE); 473 dispatchTriggerEvent(mCurrentSlider == mLeftSlider ? 474 OnTriggerListener.LEFT_HANDLE : OnTriggerListener.RIGHT_HANDLE); 475 476 // TODO: This is a place holder for the real animation. It just holds 477 // the screen for the duration of the animation for now. 478 mAnimating = true; 479 mHandler.postDelayed(new Runnable() { 480 public void run() { 481 resetView(); 482 mAnimating = false; 483 } 484 }, ANIMATION_DURATION); 485 } 486 487 if (isHorizontal() && (y <= handle.getBottom() && y >= handle.getTop()) || 488 !isHorizontal() && (x >= handle.getLeft() && x <= handle.getRight()) ) { 489 break; 490 } 491 // Intentionally fall through - we're outside tracking rectangle 492 493 case MotionEvent.ACTION_UP: 494 case MotionEvent.ACTION_CANCEL: 495 mTracking = false; 496 mTriggered = false; 497 resetView(); 498 setGrabbedState(OnTriggerListener.NO_HANDLE); 499 break; 500 } 501 } 502 503 return mTracking || super.onTouchEvent(event); 504 } 505 506 private boolean isHorizontal() { 507 return mOrientation == HORIZONTAL; 508 } 509 510 private void resetView() { 511 mLeftSlider.reset(); 512 mRightSlider.reset(); 513 onLayout(true, getLeft(), getTop(), getLeft() + getWidth(), getTop() + getHeight()); 514 } 515 516 @Override 517 protected void onLayout(boolean changed, int l, int t, int r, int b) { 518 if (!changed) return; 519 520 // Center the widgets in the view 521 mLeftSlider.layout(l, t, r, b, isHorizontal() ? Slider.ALIGN_LEFT : Slider.ALIGN_BOTTOM); 522 mRightSlider.layout(l, t, r, b, isHorizontal() ? Slider.ALIGN_RIGHT : Slider.ALIGN_TOP); 523 524 invalidate(); // TODO: be more conservative about what we're invalidating 525 } 526 527 private void moveHandle(float x, float y) { 528 final View handle = mCurrentSlider.tab; 529 final View content = mCurrentSlider.text; 530 if (isHorizontal()) { 531 int deltaX = (int) x - handle.getLeft() - (handle.getWidth() / 2); 532 handle.offsetLeftAndRight(deltaX); 533 content.offsetLeftAndRight(deltaX); 534 } else { 535 int deltaY = (int) y - handle.getTop() - (handle.getHeight() / 2); 536 handle.offsetTopAndBottom(deltaY); 537 content.offsetTopAndBottom(deltaY); 538 } 539 invalidate(); // TODO: be more conservative about what we're invalidating 540 } 541 542 /** 543 * Sets the left handle icon to a given resource. 544 * 545 * The resource should refer to a Drawable object, or use 0 to remove 546 * the icon. 547 * 548 * @param iconId the resource ID of the icon drawable 549 * @param targetId the resource of the target drawable 550 * @param barId the resource of the bar drawable (stateful) 551 * @param tabId the resource of the 552 */ 553 public void setLeftTabResources(int iconId, int targetId, int barId, int tabId) { 554 mLeftSlider.setIcon(iconId); 555 mLeftSlider.setTarget(targetId); 556 mLeftSlider.setBarBackgroundResource(barId); 557 mLeftSlider.setTabBackgroundResource(tabId); 558 mLeftSlider.updateDrawableStates(); 559 } 560 561 /** 562 * Sets the left handle hint text to a given resource string. 563 * 564 * @param resId 565 */ 566 public void setLeftHintText(int resId) { 567 mLeftSlider.setHintText(resId); 568 } 569 570 /** 571 * Sets the right handle icon to a given resource. 572 * 573 * The resource should refer to a Drawable object, or use 0 to remove 574 * the icon. 575 * 576 * @param iconId the resource ID of the icon drawable 577 * @param targetId the resource of the target drawable 578 * @param barId the resource of the bar drawable (stateful) 579 * @param tabId the resource of the 580 */ 581 public void setRightTabResources(int iconId, int targetId, int barId, int tabId) { 582 mRightSlider.setIcon(iconId); 583 mRightSlider.setTarget(targetId); 584 mRightSlider.setBarBackgroundResource(barId); 585 mRightSlider.setTabBackgroundResource(tabId); 586 mRightSlider.updateDrawableStates(); 587 } 588 589 /** 590 * Sets the left handle hint text to a given resource string. 591 * 592 * @param resId 593 */ 594 public void setRightHintText(int resId) { 595 mRightSlider.setHintText(resId); 596 } 597 598 /** 599 * Triggers haptic feedback. 600 */ 601 private synchronized void vibrate(long duration) { 602 if (mVibrator == null) { 603 mVibrator = (android.os.Vibrator) 604 getContext().getSystemService(Context.VIBRATOR_SERVICE); 605 } 606 mVibrator.vibrate(duration); 607 } 608 609 /** 610 * Registers a callback to be invoked when the user triggers an event. 611 * 612 * @param listener the OnDialTriggerListener to attach to this view 613 */ 614 public void setOnTriggerListener(OnTriggerListener listener) { 615 mOnTriggerListener = listener; 616 } 617 618 /** 619 * Dispatches a trigger event to listener. Ignored if a listener is not set. 620 * @param whichHandle the handle that triggered the event. 621 */ 622 private void dispatchTriggerEvent(int whichHandle) { 623 vibrate(VIBRATE_LONG); 624 if (mOnTriggerListener != null) { 625 mOnTriggerListener.onTrigger(this, whichHandle); 626 } 627 } 628 629 /** 630 * Sets the current grabbed state, and dispatches a grabbed state change 631 * event to our listener. 632 */ 633 private void setGrabbedState(int newState) { 634 if (newState != mGrabbedState) { 635 mGrabbedState = newState; 636 if (mOnTriggerListener != null) { 637 mOnTriggerListener.onGrabbedStateChange(this, mGrabbedState); 638 } 639 } 640 } 641 642 private class SlidingTabHandler extends Handler { 643 public void handleMessage(Message m) { 644 switch (m.what) { 645 case MSG_ANIMATE: 646 doAnimation(); 647 break; 648 } 649 } 650 } 651 652 private void doAnimation() { 653 if (mAnimating) { 654 655 } 656 } 657 658 private void log(String msg) { 659 Log.d(LOG_TAG, msg); 660 } 661} 662