• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 {
onTouchDown()96         public void onTouchDown();
onTouchUp()97         public void onTouchUp();
98 
onScrollChanged(int l, int t, int oldl, int oldt)99         public void onScrollChanged(int l, int t, int oldl, int oldt);
onTabSelected(int position)100         public void onTabSelected(int position);
101     }
102 
ContactDetailTabCarousel(Context context, AttributeSet attrs)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
onFinishInflate()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
onMeasure(int widthMeasureSpec, int heightMeasureSpec)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
onLayout(boolean changed, int l, int t, int r, int b)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 
TabClickListener(int tab)223         public TabClickListener(int tab) {
224             super();
225             mTab = tab;
226         }
227 
228         @Override
onClick(View v)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      */
animateAppear(int width, int scrollOffset)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 
updateAlphaLayers()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
onScrollChanged(int x, int y, int oldX, int oldY)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      */
reset()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      */
restoreCurrentTab(int position)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      */
restoreYCoordinate()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      */
moveToYCoordinate(int tabIndex, float y)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      */
storeYCoordinate(int tabIndex, float y)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      */
getStoredYCoordinateForTab(int tabIndex)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      */
getAllowedHorizontalScrollLength()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      */
getAllowedVerticalScrollLength()434     public int getAllowedVerticalScrollLength() {
435         return mAllowedVerticalScrollLength;
436     }
437 
438     /**
439      * Updates the tab selection.
440      */
setCurrentTab(int position)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      */
loadData(Contact contactData)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      */
setListener(Listener listener)491     public void setListener(Listener listener) {
492         mListener = listener;
493     }
494 
495     @Override
onTouch(View v, MotionEvent event)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
onInterceptTouchEvent(MotionEvent ev)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