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