1 /* 2 * Copyright (C) 2011 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 com.android.contacts.detail; 18 19 import android.content.Context; 20 import android.content.res.Resources; 21 import android.util.AttributeSet; 22 import android.util.Log; 23 import android.util.TypedValue; 24 import android.view.MotionEvent; 25 import android.view.View; 26 import android.view.View.OnTouchListener; 27 import android.view.ViewPropertyAnimator; 28 import android.widget.HorizontalScrollView; 29 import android.widget.ImageView; 30 import android.widget.TextView; 31 32 import com.android.contacts.R; 33 import com.android.contacts.model.Contact; 34 import com.android.contacts.util.MoreMath; 35 import com.android.contacts.util.SchedulingUtils; 36 37 /** 38 * This is a horizontally scrolling carousel with 2 tabs: one to see info about the contact and 39 * one to see updates from the contact. 40 */ 41 public class ContactDetailTabCarousel extends HorizontalScrollView implements OnTouchListener { 42 43 private static final String TAG = ContactDetailTabCarousel.class.getSimpleName(); 44 45 private static final int TRANSITION_TIME = 200; 46 private static final int TRANSITION_MOVE_IN_TIME = 150; 47 48 private static final int TAB_INDEX_ABOUT = 0; 49 private static final int TAB_INDEX_UPDATES = 1; 50 private static final int TAB_COUNT = 2; 51 52 /** Tab width as defined as a fraction of the screen width */ 53 private float mTabWidthScreenWidthFraction; 54 55 /** Tab height as defined as a fraction of the screen width */ 56 private float mTabHeightScreenWidthFraction; 57 58 /** Height in pixels of the shadow under the tab carousel */ 59 private int mTabShadowHeight; 60 61 private ImageView mPhotoView; 62 private View mPhotoViewOverlay; 63 private TextView mStatusView; 64 private ImageView mStatusPhotoView; 65 private final ContactDetailPhotoSetter mPhotoSetter = new ContactDetailPhotoSetter(); 66 67 private Listener mListener; 68 69 private int mCurrentTab = TAB_INDEX_ABOUT; 70 71 private View mTabAndShadowContainer; 72 private View mShadow; 73 private CarouselTab mAboutTab; 74 private View mTabDivider; 75 private CarouselTab mUpdatesTab; 76 77 /** Last Y coordinate of the carousel when the tab at the given index was selected */ 78 private final float[] mYCoordinateArray = new float[TAB_COUNT]; 79 80 private int mTabDisplayLabelHeight; 81 82 private boolean mScrollToCurrentTab = false; 83 private int mLastScrollPosition = Integer.MIN_VALUE; 84 private int mAllowedHorizontalScrollLength = Integer.MIN_VALUE; 85 private int mAllowedVerticalScrollLength = Integer.MIN_VALUE; 86 87 /** Factor to scale scroll-amount sent to listeners. */ 88 private float mScrollScaleFactor = 1.0f; 89 90 private static final float MAX_ALPHA = 0.5f; 91 92 /** 93 * Interface for callbacks invoked when the user interacts with the carousel. 94 */ 95 public interface Listener { 96 public void onTouchDown(); 97 public void onTouchUp(); 98 99 public void onScrollChanged(int l, int t, int oldl, int oldt); 100 public void onTabSelected(int position); 101 } 102 103 public ContactDetailTabCarousel(Context context, AttributeSet attrs) { 104 super(context, attrs); 105 106 setOnTouchListener(this); 107 108 Resources resources = mContext.getResources(); 109 mTabDisplayLabelHeight = resources.getDimensionPixelSize( 110 R.dimen.detail_tab_carousel_tab_label_height); 111 mTabShadowHeight = resources.getDimensionPixelSize( 112 R.dimen.detail_contact_photo_shadow_height); 113 mTabWidthScreenWidthFraction = resources.getFraction( 114 R.fraction.tab_width_screen_width_percentage, 1, 1); 115 mTabHeightScreenWidthFraction = resources.getFraction( 116 R.fraction.tab_height_screen_width_percentage, 1, 1); 117 } 118 119 @Override 120 protected void onFinishInflate() { 121 super.onFinishInflate(); 122 mTabAndShadowContainer = findViewById(R.id.tab_and_shadow_container); 123 mAboutTab = (CarouselTab) findViewById(R.id.tab_about); 124 mAboutTab.setLabel(mContext.getString(R.string.contactDetailAbout)); 125 mAboutTab.setOverlayOnClickListener(mAboutTabTouchInterceptListener); 126 127 mTabDivider = findViewById(R.id.tab_divider); 128 129 mUpdatesTab = (CarouselTab) findViewById(R.id.tab_update); 130 mUpdatesTab.setLabel(mContext.getString(R.string.contactDetailUpdates)); 131 mUpdatesTab.setOverlayOnClickListener(mUpdatesTabTouchInterceptListener); 132 133 mShadow = findViewById(R.id.shadow); 134 135 // Retrieve the photo view for the "about" tab 136 // TODO: This should be moved down to mAboutTab, so that it hosts its own controls 137 mPhotoView = (ImageView) mAboutTab.findViewById(R.id.photo); 138 mPhotoViewOverlay = mAboutTab.findViewById(R.id.photo_overlay); 139 140 // Retrieve the social update views for the "updates" tab 141 // TODO: This should be moved down to mUpdatesTab, so that it hosts its own controls 142 mStatusView = (TextView) mUpdatesTab.findViewById(R.id.status); 143 mStatusPhotoView = (ImageView) mUpdatesTab.findViewById(R.id.status_photo); 144 145 // Workaround for framework issue... it shouldn't be necessary to have a 146 // clickable object in the hierarchy, but if not the horizontal scroll 147 // behavior doesn't work. Note: the "About" tab doesn't need this 148 // because we set a real click-handler elsewhere. 149 mStatusView.setClickable(true); 150 mStatusPhotoView.setClickable(true); 151 } 152 153 @Override 154 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 155 int screenWidth = MeasureSpec.getSize(widthMeasureSpec); 156 // Compute the width of a tab as a fraction of the screen width 157 int tabWidth = Math.round(mTabWidthScreenWidthFraction * screenWidth); 158 159 // Find the allowed scrolling length by subtracting the current visible screen width 160 // from the total length of the tabs. 161 mAllowedHorizontalScrollLength = tabWidth * TAB_COUNT - screenWidth; 162 163 // Scrolling by mAllowedHorizontalScrollLength causes listeners to 164 // scroll by the entire screen amount; compute the scale-factor 165 // necessary to make this so. 166 if (mAllowedHorizontalScrollLength == 0) { 167 // Guard against divide-by-zero. 168 // Note: this hard-coded value prevents a crash, but won't result in the 169 // desired scrolling behavior. We rely on the framework calling onMeasure() 170 // again with a non-zero screen width. 171 mScrollScaleFactor = 1.0f; 172 Log.w(TAG, "set scale-factor to 1.0 to avoid divide-by-zero"); 173 } else { 174 mScrollScaleFactor = screenWidth / mAllowedHorizontalScrollLength; 175 } 176 177 int tabHeight = Math.round(screenWidth * mTabHeightScreenWidthFraction) + mTabShadowHeight; 178 // Set the child {@link LinearLayout} to be TAB_COUNT * the computed tab width so that the 179 // {@link LinearLayout}'s children (which are the tabs) will evenly split that width. 180 if (getChildCount() > 0) { 181 View child = getChildAt(0); 182 183 // add 1 dip of separation between the tabs 184 final int seperatorPixels = 185 (int)(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, 186 getResources().getDisplayMetrics()) + 0.5f); 187 188 child.measure( 189 MeasureSpec.makeMeasureSpec( 190 TAB_COUNT * tabWidth + 191 (TAB_COUNT - 1) * seperatorPixels, MeasureSpec.EXACTLY), 192 MeasureSpec.makeMeasureSpec(tabHeight, MeasureSpec.EXACTLY)); 193 } 194 195 mAllowedVerticalScrollLength = tabHeight - mTabDisplayLabelHeight - mTabShadowHeight; 196 setMeasuredDimension( 197 resolveSize(screenWidth, widthMeasureSpec), 198 resolveSize(tabHeight, heightMeasureSpec)); 199 } 200 201 @Override 202 protected void onLayout(boolean changed, int l, int t, int r, int b) { 203 super.onLayout(changed, l, t, r, b); 204 205 // Defer this stuff until after the layout has finished. This is because 206 // updateAlphaLayers() ultimately results in another layout request, and 207 // the framework currently can't handle this safely. 208 if (!mScrollToCurrentTab) return; 209 mScrollToCurrentTab = false; 210 SchedulingUtils.doAfterLayout(this, new Runnable() { 211 @Override 212 public void run() { 213 scrollTo(mCurrentTab == TAB_INDEX_ABOUT ? 0 : mAllowedHorizontalScrollLength, 0); 214 updateAlphaLayers(); 215 } 216 }); 217 } 218 219 /** When clicked, selects the corresponding tab. */ 220 private class TabClickListener implements OnClickListener { 221 private final int mTab; 222 223 public TabClickListener(int tab) { 224 super(); 225 mTab = tab; 226 } 227 228 @Override 229 public void onClick(View v) { 230 mListener.onTabSelected(mTab); 231 } 232 } 233 234 private final TabClickListener mAboutTabTouchInterceptListener = 235 new TabClickListener(TAB_INDEX_ABOUT); 236 237 private final TabClickListener mUpdatesTabTouchInterceptListener = 238 new TabClickListener(TAB_INDEX_UPDATES); 239 240 /** 241 * Does in "appear" animation to allow a seamless transition from 242 * the "No updates" mode. 243 * @param width Width of the container. As we haven't been layed out yet, we can't know 244 * @param scrollOffset The offset by how far we scrolled, where 0=not scrolled, -x=scrolled by 245 * x pixels, Integer.MIN_VALUE=scrolled so far that the image is not visible in "no updates" 246 * mode of this screen 247 */ 248 public void animateAppear(int width, int scrollOffset) { 249 final float photoHeight = mTabHeightScreenWidthFraction * width; 250 final boolean animateZoomAndFade; 251 int pixelsToScrollVertically = 0; 252 253 // Depending on how far we are scrolled down, there is one of three animations: 254 // - Zoom and fade the picture (if it is still visible) 255 // - Scroll, zoom and fade (if the picture is mostly invisible and we now have a 256 // bigger visible region due to the pinning) 257 // - Just scroll if the picture is completely invisible. This time, no zoom is needed 258 if (scrollOffset == Integer.MIN_VALUE) { 259 // animate in completely by scrolling. no need for zooming here 260 pixelsToScrollVertically = mTabDisplayLabelHeight; 261 animateZoomAndFade = false; 262 } else { 263 final int pixelsOfPhotoLeft = Math.round(photoHeight) + scrollOffset; 264 if (pixelsOfPhotoLeft > mTabDisplayLabelHeight) { 265 // nothing to scroll 266 pixelsToScrollVertically = 0; 267 } else { 268 pixelsToScrollVertically = mTabDisplayLabelHeight - pixelsOfPhotoLeft; 269 } 270 animateZoomAndFade = true; 271 } 272 273 if (pixelsToScrollVertically != 0) { 274 // We can't animate ourselves here, because our own translation is needed for the user's 275 // scrolling. Instead, we use our only child. As we are transparent, that is just as 276 // good 277 mTabAndShadowContainer.setTranslationY(-pixelsToScrollVertically); 278 final ViewPropertyAnimator animator = mTabAndShadowContainer.animate(); 279 animator.translationY(0.0f); 280 animator.setDuration(TRANSITION_MOVE_IN_TIME); 281 } 282 283 if (animateZoomAndFade) { 284 // Hack: We have two types of possible layouts: 285 // If the picture is square, it is square in both "with updates" and "without updates" 286 // --> no need for scale animation here 287 // example: 10inch tablet portrait 288 // If the picture is non-square, it is full-width in "without updates" and something 289 // arbitrary in "with updates" 290 // --> do animation with container 291 // example: 4.6inch phone portrait 292 final boolean squarePicture = 293 mTabWidthScreenWidthFraction == mTabHeightScreenWidthFraction; 294 final int firstTransitionTime; 295 if (squarePicture) { 296 firstTransitionTime = 0; 297 } else { 298 // For x, we need to scale our container so we'll animate the whole tab 299 // (unfortunately, we need to have the text invisible during this transition as it 300 // would also be stretched) 301 float revScale = 1.0f/mTabWidthScreenWidthFraction; 302 mAboutTab.setScaleX(revScale); 303 mAboutTab.setPivotX(0.0f); 304 final ViewPropertyAnimator aboutAnimator = mAboutTab.animate(); 305 aboutAnimator.setDuration(TRANSITION_TIME); 306 aboutAnimator.scaleX(1.0f); 307 308 // For y, we need to scale only the picture itself because we want it to be cropped 309 mPhotoView.setScaleY(revScale); 310 mPhotoView.setPivotY(photoHeight * 0.5f); 311 final ViewPropertyAnimator photoAnimator = mPhotoView.animate(); 312 photoAnimator.setDuration(TRANSITION_TIME); 313 photoAnimator.scaleY(1.0f); 314 firstTransitionTime = TRANSITION_TIME; 315 } 316 317 // Animate in the labels after the above transition is finished 318 mAboutTab.fadeInLabelViewAnimator(firstTransitionTime, true); 319 mUpdatesTab.fadeInLabelViewAnimator(firstTransitionTime, false); 320 321 final float pixelsToTranslate = (1.0f - mTabWidthScreenWidthFraction) * width; 322 // Views to translate 323 for (View view : new View[] { mUpdatesTab, mTabDivider }) { 324 view.setTranslationX(pixelsToTranslate); 325 final ViewPropertyAnimator translateAnimator = view.animate(); 326 translateAnimator.translationX(0.0f); 327 translateAnimator.setDuration(TRANSITION_TIME); 328 } 329 330 // Another hack: If the picture is square, there is no shadow in "Without updates" 331 // --> fade it in after the translations are done 332 if (squarePicture) { 333 mShadow.setAlpha(0.0f); 334 mShadow.animate().setStartDelay(TRANSITION_TIME).alpha(1.0f); 335 } 336 } 337 } 338 339 private void updateAlphaLayers() { 340 float alpha = mLastScrollPosition * MAX_ALPHA / mAllowedHorizontalScrollLength; 341 alpha = MoreMath.clamp(alpha, 0.0f, 1.0f); 342 mAboutTab.setAlphaLayerValue(alpha); 343 mUpdatesTab.setAlphaLayerValue(MAX_ALPHA - alpha); 344 } 345 346 @Override 347 protected void onScrollChanged(int x, int y, int oldX, int oldY) { 348 super.onScrollChanged(x, y, oldX, oldY); 349 350 // Guard against framework issue where onScrollChanged() is called twice 351 // for each touch-move event. This wreaked havoc on the tab-carousel: the 352 // view-pager moved twice as fast as it should because we called fakeDragBy() 353 // twice with the same value. 354 if (mLastScrollPosition == x) return; 355 356 // Since we never completely scroll the about/updates tabs off-screen, 357 // the draggable range is less than the width of the carousel. Our 358 // listeners don't care about this... if we scroll 75% percent of our 359 // draggable range, they want to scroll 75% of the entire carousel 360 // width, not the same number of pixels that we scrolled. 361 int scaledL = (int) (x * mScrollScaleFactor); 362 int oldScaledL = (int) (oldX * mScrollScaleFactor); 363 mListener.onScrollChanged(scaledL, y, oldScaledL, oldY); 364 365 mLastScrollPosition = x; 366 updateAlphaLayers(); 367 } 368 369 /** 370 * Reset the carousel to the start position (i.e. because new data will be loaded in for a 371 * different contact). 372 */ 373 public void reset() { 374 scrollTo(0, 0); 375 setCurrentTab(0); 376 moveToYCoordinate(0, 0); 377 } 378 379 /** 380 * Set the current tab that should be restored when the view is first laid out. 381 */ 382 public void restoreCurrentTab(int position) { 383 setCurrentTab(position); 384 // It is only possible to scroll the view after onMeasure() has been called (where the 385 // allowed horizontal scroll length is determined). Hence, set a flag that will be read 386 // in onLayout() after the children and this view have finished being laid out. 387 mScrollToCurrentTab = true; 388 } 389 390 /** 391 * Restore the Y position of this view to the last manually requested value. This can be done 392 * after the parent has been re-laid out again, where this view's position could have been 393 * lost if the view laid outside its parent's bounds. 394 */ 395 public void restoreYCoordinate() { 396 setY(getStoredYCoordinateForTab(mCurrentTab)); 397 } 398 399 /** 400 * Request that the view move to the given Y coordinate. Also store the Y coordinate as the 401 * last requested Y coordinate for the given tabIndex. 402 */ 403 public void moveToYCoordinate(int tabIndex, float y) { 404 setY(y); 405 storeYCoordinate(tabIndex, y); 406 } 407 408 /** 409 * Store this information as the last requested Y coordinate for the given tabIndex. 410 */ 411 public void storeYCoordinate(int tabIndex, float y) { 412 mYCoordinateArray[tabIndex] = y; 413 } 414 415 /** 416 * Returns the stored Y coordinate of this view the last time the user was on the selected 417 * tab given by tabIndex. 418 */ 419 public float getStoredYCoordinateForTab(int tabIndex) { 420 return mYCoordinateArray[tabIndex]; 421 } 422 423 /** 424 * Returns the number of pixels that this view can be scrolled horizontally. 425 */ 426 public int getAllowedHorizontalScrollLength() { 427 return mAllowedHorizontalScrollLength; 428 } 429 430 /** 431 * Returns the number of pixels that this view can be scrolled vertically while still allowing 432 * the tab labels to still show. 433 */ 434 public int getAllowedVerticalScrollLength() { 435 return mAllowedVerticalScrollLength; 436 } 437 438 /** 439 * Updates the tab selection. 440 */ 441 public void setCurrentTab(int position) { 442 final CarouselTab selected, deselected; 443 444 switch (position) { 445 case TAB_INDEX_ABOUT: 446 selected = mAboutTab; 447 deselected = mUpdatesTab; 448 break; 449 case TAB_INDEX_UPDATES: 450 selected = mUpdatesTab; 451 deselected = mAboutTab; 452 break; 453 default: 454 throw new IllegalStateException("Invalid tab position " + position); 455 } 456 selected.showSelectedState(); 457 selected.setOverlayClickable(false); 458 deselected.showDeselectedState(); 459 deselected.setOverlayClickable(true); 460 mCurrentTab = position; 461 } 462 463 /** 464 * Loads the data from the Loader-Result. This is the only function that has to be called 465 * from the outside to fully setup the View 466 */ 467 public void loadData(Contact contactData) { 468 if (contactData == null) return; 469 470 // TODO: Move this into the {@link CarouselTab} class when the updates 471 // fragment code is more finalized. 472 final boolean expandOnClick = contactData.getPhotoUri() != null; 473 final OnClickListener listener = mPhotoSetter.setupContactPhotoForClick( 474 mContext, contactData, mPhotoView, expandOnClick); 475 476 if (expandOnClick || contactData.isWritableContact(mContext)) { 477 mPhotoViewOverlay.setOnClickListener(listener); 478 } else { 479 // Work around framework issue... if we instead use 480 // setClickable(false), then we can't swipe horizontally. 481 mPhotoViewOverlay.setOnClickListener(null); 482 } 483 484 ContactDetailDisplayUtils.setSocialSnippet( 485 mContext, contactData, mStatusView, mStatusPhotoView); 486 } 487 488 /** 489 * Set the given {@link Listener} to handle carousel events. 490 */ 491 public void setListener(Listener listener) { 492 mListener = listener; 493 } 494 495 @Override 496 public boolean onTouch(View v, MotionEvent event) { 497 switch (event.getAction()) { 498 case MotionEvent.ACTION_DOWN: 499 mListener.onTouchDown(); 500 return true; 501 case MotionEvent.ACTION_UP: 502 mListener.onTouchUp(); 503 return true; 504 } 505 return super.onTouchEvent(event); 506 } 507 508 @Override 509 public boolean onInterceptTouchEvent(MotionEvent ev) { 510 boolean interceptTouch = super.onInterceptTouchEvent(ev); 511 if (interceptTouch) { 512 mListener.onTouchDown(); 513 } 514 return interceptTouch; 515 } 516 } 517