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