Home | History | Annotate | Download | only in detail
      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.common.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