| /* |
| * Copyright (C) 2011 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.contacts.detail; |
| |
| import android.content.Context; |
| import android.content.res.Resources; |
| import android.util.AttributeSet; |
| import android.util.Log; |
| import android.util.TypedValue; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.View.OnTouchListener; |
| import android.view.ViewPropertyAnimator; |
| import android.widget.HorizontalScrollView; |
| import android.widget.ImageView; |
| import android.widget.TextView; |
| |
| import com.android.contacts.R; |
| import com.android.contacts.model.Contact; |
| import com.android.contacts.util.MoreMath; |
| import com.android.contacts.util.SchedulingUtils; |
| |
| /** |
| * This is a horizontally scrolling carousel with 2 tabs: one to see info about the contact and |
| * one to see updates from the contact. |
| */ |
| public class ContactDetailTabCarousel extends HorizontalScrollView implements OnTouchListener { |
| |
| private static final String TAG = ContactDetailTabCarousel.class.getSimpleName(); |
| |
| private static final int TRANSITION_TIME = 200; |
| private static final int TRANSITION_MOVE_IN_TIME = 150; |
| |
| private static final int TAB_INDEX_ABOUT = 0; |
| private static final int TAB_INDEX_UPDATES = 1; |
| private static final int TAB_COUNT = 2; |
| |
| /** Tab width as defined as a fraction of the screen width */ |
| private float mTabWidthScreenWidthFraction; |
| |
| /** Tab height as defined as a fraction of the screen width */ |
| private float mTabHeightScreenWidthFraction; |
| |
| /** Height in pixels of the shadow under the tab carousel */ |
| private int mTabShadowHeight; |
| |
| private ImageView mPhotoView; |
| private View mPhotoViewOverlay; |
| private TextView mStatusView; |
| private ImageView mStatusPhotoView; |
| private final ContactDetailPhotoSetter mPhotoSetter = new ContactDetailPhotoSetter(); |
| |
| private Listener mListener; |
| |
| private int mCurrentTab = TAB_INDEX_ABOUT; |
| |
| private View mTabAndShadowContainer; |
| private View mShadow; |
| private CarouselTab mAboutTab; |
| private View mTabDivider; |
| private CarouselTab mUpdatesTab; |
| |
| /** Last Y coordinate of the carousel when the tab at the given index was selected */ |
| private final float[] mYCoordinateArray = new float[TAB_COUNT]; |
| |
| private int mTabDisplayLabelHeight; |
| |
| private boolean mScrollToCurrentTab = false; |
| private int mLastScrollPosition = Integer.MIN_VALUE; |
| private int mAllowedHorizontalScrollLength = Integer.MIN_VALUE; |
| private int mAllowedVerticalScrollLength = Integer.MIN_VALUE; |
| |
| /** Factor to scale scroll-amount sent to listeners. */ |
| private float mScrollScaleFactor = 1.0f; |
| |
| private static final float MAX_ALPHA = 0.5f; |
| |
| /** |
| * Interface for callbacks invoked when the user interacts with the carousel. |
| */ |
| public interface Listener { |
| public void onTouchDown(); |
| public void onTouchUp(); |
| |
| public void onScrollChanged(int l, int t, int oldl, int oldt); |
| public void onTabSelected(int position); |
| } |
| |
| public ContactDetailTabCarousel(Context context, AttributeSet attrs) { |
| super(context, attrs); |
| |
| setOnTouchListener(this); |
| |
| Resources resources = mContext.getResources(); |
| mTabDisplayLabelHeight = resources.getDimensionPixelSize( |
| R.dimen.detail_tab_carousel_tab_label_height); |
| mTabShadowHeight = resources.getDimensionPixelSize( |
| R.dimen.detail_contact_photo_shadow_height); |
| mTabWidthScreenWidthFraction = resources.getFraction( |
| R.fraction.tab_width_screen_width_percentage, 1, 1); |
| mTabHeightScreenWidthFraction = resources.getFraction( |
| R.fraction.tab_height_screen_width_percentage, 1, 1); |
| } |
| |
| @Override |
| protected void onFinishInflate() { |
| super.onFinishInflate(); |
| mTabAndShadowContainer = findViewById(R.id.tab_and_shadow_container); |
| mAboutTab = (CarouselTab) findViewById(R.id.tab_about); |
| mAboutTab.setLabel(mContext.getString(R.string.contactDetailAbout)); |
| mAboutTab.setOverlayOnClickListener(mAboutTabTouchInterceptListener); |
| |
| mTabDivider = findViewById(R.id.tab_divider); |
| |
| mUpdatesTab = (CarouselTab) findViewById(R.id.tab_update); |
| mUpdatesTab.setLabel(mContext.getString(R.string.contactDetailUpdates)); |
| mUpdatesTab.setOverlayOnClickListener(mUpdatesTabTouchInterceptListener); |
| |
| mShadow = findViewById(R.id.shadow); |
| |
| // Retrieve the photo view for the "about" tab |
| // TODO: This should be moved down to mAboutTab, so that it hosts its own controls |
| mPhotoView = (ImageView) mAboutTab.findViewById(R.id.photo); |
| mPhotoViewOverlay = mAboutTab.findViewById(R.id.photo_overlay); |
| |
| // Retrieve the social update views for the "updates" tab |
| // TODO: This should be moved down to mUpdatesTab, so that it hosts its own controls |
| mStatusView = (TextView) mUpdatesTab.findViewById(R.id.status); |
| mStatusPhotoView = (ImageView) mUpdatesTab.findViewById(R.id.status_photo); |
| |
| // Workaround for framework issue... it shouldn't be necessary to have a |
| // clickable object in the hierarchy, but if not the horizontal scroll |
| // behavior doesn't work. Note: the "About" tab doesn't need this |
| // because we set a real click-handler elsewhere. |
| mStatusView.setClickable(true); |
| mStatusPhotoView.setClickable(true); |
| } |
| |
| @Override |
| protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { |
| int screenWidth = MeasureSpec.getSize(widthMeasureSpec); |
| // Compute the width of a tab as a fraction of the screen width |
| int tabWidth = Math.round(mTabWidthScreenWidthFraction * screenWidth); |
| |
| // Find the allowed scrolling length by subtracting the current visible screen width |
| // from the total length of the tabs. |
| mAllowedHorizontalScrollLength = tabWidth * TAB_COUNT - screenWidth; |
| |
| // Scrolling by mAllowedHorizontalScrollLength causes listeners to |
| // scroll by the entire screen amount; compute the scale-factor |
| // necessary to make this so. |
| if (mAllowedHorizontalScrollLength == 0) { |
| // Guard against divide-by-zero. |
| // Note: this hard-coded value prevents a crash, but won't result in the |
| // desired scrolling behavior. We rely on the framework calling onMeasure() |
| // again with a non-zero screen width. |
| mScrollScaleFactor = 1.0f; |
| Log.w(TAG, "set scale-factor to 1.0 to avoid divide-by-zero"); |
| } else { |
| mScrollScaleFactor = screenWidth / mAllowedHorizontalScrollLength; |
| } |
| |
| int tabHeight = Math.round(screenWidth * mTabHeightScreenWidthFraction) + mTabShadowHeight; |
| // Set the child {@link LinearLayout} to be TAB_COUNT * the computed tab width so that the |
| // {@link LinearLayout}'s children (which are the tabs) will evenly split that width. |
| if (getChildCount() > 0) { |
| View child = getChildAt(0); |
| |
| // add 1 dip of separation between the tabs |
| final int seperatorPixels = |
| (int)(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, |
| getResources().getDisplayMetrics()) + 0.5f); |
| |
| child.measure( |
| MeasureSpec.makeMeasureSpec( |
| TAB_COUNT * tabWidth + |
| (TAB_COUNT - 1) * seperatorPixels, MeasureSpec.EXACTLY), |
| MeasureSpec.makeMeasureSpec(tabHeight, MeasureSpec.EXACTLY)); |
| } |
| |
| mAllowedVerticalScrollLength = tabHeight - mTabDisplayLabelHeight - mTabShadowHeight; |
| setMeasuredDimension( |
| resolveSize(screenWidth, widthMeasureSpec), |
| resolveSize(tabHeight, heightMeasureSpec)); |
| } |
| |
| @Override |
| protected void onLayout(boolean changed, int l, int t, int r, int b) { |
| super.onLayout(changed, l, t, r, b); |
| |
| // Defer this stuff until after the layout has finished. This is because |
| // updateAlphaLayers() ultimately results in another layout request, and |
| // the framework currently can't handle this safely. |
| if (!mScrollToCurrentTab) return; |
| mScrollToCurrentTab = false; |
| SchedulingUtils.doAfterLayout(this, new Runnable() { |
| @Override |
| public void run() { |
| scrollTo(mCurrentTab == TAB_INDEX_ABOUT ? 0 : mAllowedHorizontalScrollLength, 0); |
| updateAlphaLayers(); |
| } |
| }); |
| } |
| |
| /** When clicked, selects the corresponding tab. */ |
| private class TabClickListener implements OnClickListener { |
| private final int mTab; |
| |
| public TabClickListener(int tab) { |
| super(); |
| mTab = tab; |
| } |
| |
| @Override |
| public void onClick(View v) { |
| mListener.onTabSelected(mTab); |
| } |
| } |
| |
| private final TabClickListener mAboutTabTouchInterceptListener = |
| new TabClickListener(TAB_INDEX_ABOUT); |
| |
| private final TabClickListener mUpdatesTabTouchInterceptListener = |
| new TabClickListener(TAB_INDEX_UPDATES); |
| |
| /** |
| * Does in "appear" animation to allow a seamless transition from |
| * the "No updates" mode. |
| * @param width Width of the container. As we haven't been layed out yet, we can't know |
| * @param scrollOffset The offset by how far we scrolled, where 0=not scrolled, -x=scrolled by |
| * x pixels, Integer.MIN_VALUE=scrolled so far that the image is not visible in "no updates" |
| * mode of this screen |
| */ |
| public void animateAppear(int width, int scrollOffset) { |
| final float photoHeight = mTabHeightScreenWidthFraction * width; |
| final boolean animateZoomAndFade; |
| int pixelsToScrollVertically = 0; |
| |
| // Depending on how far we are scrolled down, there is one of three animations: |
| // - Zoom and fade the picture (if it is still visible) |
| // - Scroll, zoom and fade (if the picture is mostly invisible and we now have a |
| // bigger visible region due to the pinning) |
| // - Just scroll if the picture is completely invisible. This time, no zoom is needed |
| if (scrollOffset == Integer.MIN_VALUE) { |
| // animate in completely by scrolling. no need for zooming here |
| pixelsToScrollVertically = mTabDisplayLabelHeight; |
| animateZoomAndFade = false; |
| } else { |
| final int pixelsOfPhotoLeft = Math.round(photoHeight) + scrollOffset; |
| if (pixelsOfPhotoLeft > mTabDisplayLabelHeight) { |
| // nothing to scroll |
| pixelsToScrollVertically = 0; |
| } else { |
| pixelsToScrollVertically = mTabDisplayLabelHeight - pixelsOfPhotoLeft; |
| } |
| animateZoomAndFade = true; |
| } |
| |
| if (pixelsToScrollVertically != 0) { |
| // We can't animate ourselves here, because our own translation is needed for the user's |
| // scrolling. Instead, we use our only child. As we are transparent, that is just as |
| // good |
| mTabAndShadowContainer.setTranslationY(-pixelsToScrollVertically); |
| final ViewPropertyAnimator animator = mTabAndShadowContainer.animate(); |
| animator.translationY(0.0f); |
| animator.setDuration(TRANSITION_MOVE_IN_TIME); |
| } |
| |
| if (animateZoomAndFade) { |
| // Hack: We have two types of possible layouts: |
| // If the picture is square, it is square in both "with updates" and "without updates" |
| // --> no need for scale animation here |
| // example: 10inch tablet portrait |
| // If the picture is non-square, it is full-width in "without updates" and something |
| // arbitrary in "with updates" |
| // --> do animation with container |
| // example: 4.6inch phone portrait |
| final boolean squarePicture = |
| mTabWidthScreenWidthFraction == mTabHeightScreenWidthFraction; |
| final int firstTransitionTime; |
| if (squarePicture) { |
| firstTransitionTime = 0; |
| } else { |
| // For x, we need to scale our container so we'll animate the whole tab |
| // (unfortunately, we need to have the text invisible during this transition as it |
| // would also be stretched) |
| float revScale = 1.0f/mTabWidthScreenWidthFraction; |
| mAboutTab.setScaleX(revScale); |
| mAboutTab.setPivotX(0.0f); |
| final ViewPropertyAnimator aboutAnimator = mAboutTab.animate(); |
| aboutAnimator.setDuration(TRANSITION_TIME); |
| aboutAnimator.scaleX(1.0f); |
| |
| // For y, we need to scale only the picture itself because we want it to be cropped |
| mPhotoView.setScaleY(revScale); |
| mPhotoView.setPivotY(photoHeight * 0.5f); |
| final ViewPropertyAnimator photoAnimator = mPhotoView.animate(); |
| photoAnimator.setDuration(TRANSITION_TIME); |
| photoAnimator.scaleY(1.0f); |
| firstTransitionTime = TRANSITION_TIME; |
| } |
| |
| // Animate in the labels after the above transition is finished |
| mAboutTab.fadeInLabelViewAnimator(firstTransitionTime, true); |
| mUpdatesTab.fadeInLabelViewAnimator(firstTransitionTime, false); |
| |
| final float pixelsToTranslate = (1.0f - mTabWidthScreenWidthFraction) * width; |
| // Views to translate |
| for (View view : new View[] { mUpdatesTab, mTabDivider }) { |
| view.setTranslationX(pixelsToTranslate); |
| final ViewPropertyAnimator translateAnimator = view.animate(); |
| translateAnimator.translationX(0.0f); |
| translateAnimator.setDuration(TRANSITION_TIME); |
| } |
| |
| // Another hack: If the picture is square, there is no shadow in "Without updates" |
| // --> fade it in after the translations are done |
| if (squarePicture) { |
| mShadow.setAlpha(0.0f); |
| mShadow.animate().setStartDelay(TRANSITION_TIME).alpha(1.0f); |
| } |
| } |
| } |
| |
| private void updateAlphaLayers() { |
| float alpha = mLastScrollPosition * MAX_ALPHA / mAllowedHorizontalScrollLength; |
| alpha = MoreMath.clamp(alpha, 0.0f, 1.0f); |
| mAboutTab.setAlphaLayerValue(alpha); |
| mUpdatesTab.setAlphaLayerValue(MAX_ALPHA - alpha); |
| } |
| |
| @Override |
| protected void onScrollChanged(int x, int y, int oldX, int oldY) { |
| super.onScrollChanged(x, y, oldX, oldY); |
| |
| // Guard against framework issue where onScrollChanged() is called twice |
| // for each touch-move event. This wreaked havoc on the tab-carousel: the |
| // view-pager moved twice as fast as it should because we called fakeDragBy() |
| // twice with the same value. |
| if (mLastScrollPosition == x) return; |
| |
| // Since we never completely scroll the about/updates tabs off-screen, |
| // the draggable range is less than the width of the carousel. Our |
| // listeners don't care about this... if we scroll 75% percent of our |
| // draggable range, they want to scroll 75% of the entire carousel |
| // width, not the same number of pixels that we scrolled. |
| int scaledL = (int) (x * mScrollScaleFactor); |
| int oldScaledL = (int) (oldX * mScrollScaleFactor); |
| mListener.onScrollChanged(scaledL, y, oldScaledL, oldY); |
| |
| mLastScrollPosition = x; |
| updateAlphaLayers(); |
| } |
| |
| /** |
| * Reset the carousel to the start position (i.e. because new data will be loaded in for a |
| * different contact). |
| */ |
| public void reset() { |
| scrollTo(0, 0); |
| setCurrentTab(0); |
| moveToYCoordinate(0, 0); |
| } |
| |
| /** |
| * Set the current tab that should be restored when the view is first laid out. |
| */ |
| public void restoreCurrentTab(int position) { |
| setCurrentTab(position); |
| // It is only possible to scroll the view after onMeasure() has been called (where the |
| // allowed horizontal scroll length is determined). Hence, set a flag that will be read |
| // in onLayout() after the children and this view have finished being laid out. |
| mScrollToCurrentTab = true; |
| } |
| |
| /** |
| * Restore the Y position of this view to the last manually requested value. This can be done |
| * after the parent has been re-laid out again, where this view's position could have been |
| * lost if the view laid outside its parent's bounds. |
| */ |
| public void restoreYCoordinate() { |
| setY(getStoredYCoordinateForTab(mCurrentTab)); |
| } |
| |
| /** |
| * Request that the view move to the given Y coordinate. Also store the Y coordinate as the |
| * last requested Y coordinate for the given tabIndex. |
| */ |
| public void moveToYCoordinate(int tabIndex, float y) { |
| setY(y); |
| storeYCoordinate(tabIndex, y); |
| } |
| |
| /** |
| * Store this information as the last requested Y coordinate for the given tabIndex. |
| */ |
| public void storeYCoordinate(int tabIndex, float y) { |
| mYCoordinateArray[tabIndex] = y; |
| } |
| |
| /** |
| * Returns the stored Y coordinate of this view the last time the user was on the selected |
| * tab given by tabIndex. |
| */ |
| public float getStoredYCoordinateForTab(int tabIndex) { |
| return mYCoordinateArray[tabIndex]; |
| } |
| |
| /** |
| * Returns the number of pixels that this view can be scrolled horizontally. |
| */ |
| public int getAllowedHorizontalScrollLength() { |
| return mAllowedHorizontalScrollLength; |
| } |
| |
| /** |
| * Returns the number of pixels that this view can be scrolled vertically while still allowing |
| * the tab labels to still show. |
| */ |
| public int getAllowedVerticalScrollLength() { |
| return mAllowedVerticalScrollLength; |
| } |
| |
| /** |
| * Updates the tab selection. |
| */ |
| public void setCurrentTab(int position) { |
| final CarouselTab selected, deselected; |
| |
| switch (position) { |
| case TAB_INDEX_ABOUT: |
| selected = mAboutTab; |
| deselected = mUpdatesTab; |
| break; |
| case TAB_INDEX_UPDATES: |
| selected = mUpdatesTab; |
| deselected = mAboutTab; |
| break; |
| default: |
| throw new IllegalStateException("Invalid tab position " + position); |
| } |
| selected.showSelectedState(); |
| selected.setOverlayClickable(false); |
| deselected.showDeselectedState(); |
| deselected.setOverlayClickable(true); |
| mCurrentTab = position; |
| } |
| |
| /** |
| * Loads the data from the Loader-Result. This is the only function that has to be called |
| * from the outside to fully setup the View |
| */ |
| public void loadData(Contact contactData) { |
| if (contactData == null) return; |
| |
| // TODO: Move this into the {@link CarouselTab} class when the updates |
| // fragment code is more finalized. |
| final boolean expandOnClick = contactData.getPhotoUri() != null; |
| final OnClickListener listener = mPhotoSetter.setupContactPhotoForClick( |
| mContext, contactData, mPhotoView, expandOnClick); |
| |
| if (expandOnClick || contactData.isWritableContact(mContext)) { |
| mPhotoViewOverlay.setOnClickListener(listener); |
| } else { |
| // Work around framework issue... if we instead use |
| // setClickable(false), then we can't swipe horizontally. |
| mPhotoViewOverlay.setOnClickListener(null); |
| } |
| |
| ContactDetailDisplayUtils.setSocialSnippet( |
| mContext, contactData, mStatusView, mStatusPhotoView); |
| } |
| |
| /** |
| * Set the given {@link Listener} to handle carousel events. |
| */ |
| public void setListener(Listener listener) { |
| mListener = listener; |
| } |
| |
| @Override |
| public boolean onTouch(View v, MotionEvent event) { |
| switch (event.getAction()) { |
| case MotionEvent.ACTION_DOWN: |
| mListener.onTouchDown(); |
| return true; |
| case MotionEvent.ACTION_UP: |
| mListener.onTouchUp(); |
| return true; |
| } |
| return super.onTouchEvent(event); |
| } |
| |
| @Override |
| public boolean onInterceptTouchEvent(MotionEvent ev) { |
| boolean interceptTouch = super.onInterceptTouchEvent(ev); |
| if (interceptTouch) { |
| mListener.onTouchDown(); |
| } |
| return interceptTouch; |
| } |
| } |