1 /*
2  * Copyright (C) 2015 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.messaging.ui.mediapicker;
18 
19 import android.content.Context;
20 import android.content.res.Resources;
21 import android.os.Handler;
22 import android.util.AttributeSet;
23 import android.view.MotionEvent;
24 import android.view.View;
25 import android.view.ViewConfiguration;
26 import android.view.ViewGroup;
27 import android.view.animation.Animation;
28 import android.view.animation.Transformation;
29 import android.widget.LinearLayout;
30 
31 import com.android.messaging.R;
32 import com.android.messaging.ui.PagingAwareViewPager;
33 import com.android.messaging.util.Assert;
34 import com.android.messaging.util.OsUtil;
35 import com.android.messaging.util.UiUtils;
36 
37 /**
38  * Custom layout panel which makes the MediaPicker animations seamless and synchronized
39  * Designed to be very specific to the MediaPicker's usage
40  */
41 public class MediaPickerPanel extends ViewGroup {
42     /**
43      * The window of time in which we might to decide to reinterpret the intent of a gesture.
44      */
45     private static final long TOUCH_RECAPTURE_WINDOW_MS = 500L;
46 
47     // The two view components to layout
48     private LinearLayout mTabStrip;
49     private boolean mFullScreenOnly;
50     private PagingAwareViewPager mViewPager;
51 
52     /**
53      * True if the MediaPicker is full screen or animating into it
54      */
55     private boolean mFullScreen;
56 
57     /**
58      * True if the MediaPicker is open at all
59      */
60     private boolean mExpanded;
61 
62     /**
63      * The current desired height of the MediaPicker.  This property may be animated and the
64      * measure pass uses it to determine what size the components are.
65      */
66     private int mCurrentDesiredHeight;
67 
68     private final Handler mHandler = new Handler();
69 
70     /**
71      * The media picker for dispatching events to the MediaPicker's listener
72      */
73     private MediaPicker mMediaPicker;
74 
75     /**
76      * The computed default "half-screen" height of the view pager in px
77      */
78     private final int mDefaultViewPagerHeight;
79 
80     /**
81      * The action bar height used to compute the padding on the view pager when it's full screen.
82      */
83     private final int mActionBarHeight;
84 
85     private TouchHandler mTouchHandler;
86 
87     static final int PAGE_NOT_SET = -1;
88 
MediaPickerPanel(final Context context, final AttributeSet attrs)89     public MediaPickerPanel(final Context context, final AttributeSet attrs) {
90         super(context, attrs);
91         // Cache the computed dimension
92         mDefaultViewPagerHeight = getResources().getDimensionPixelSize(
93                 R.dimen.mediapicker_default_chooser_height);
94         mActionBarHeight = getResources().getDimensionPixelSize(R.dimen.action_bar_height);
95     }
96 
97     @Override
onFinishInflate()98     protected void onFinishInflate() {
99         super.onFinishInflate();
100         mTabStrip = (LinearLayout) findViewById(R.id.mediapicker_tabstrip);
101         mViewPager = (PagingAwareViewPager) findViewById(R.id.mediapicker_view_pager);
102         mTouchHandler = new TouchHandler();
103         setOnTouchListener(mTouchHandler);
104         mViewPager.setOnTouchListener(mTouchHandler);
105 
106         // Make sure full screen mode is updated in landscape mode change when the panel is open.
107         addOnLayoutChangeListener(new OnLayoutChangeListener() {
108             private boolean mLandscapeMode = UiUtils.isLandscapeMode();
109 
110             @Override
111             public void onLayoutChange(View v, int left, int top, int right, int bottom,
112                     int oldLeft, int oldTop, int oldRight, int oldBottom) {
113                 final boolean newLandscapeMode = UiUtils.isLandscapeMode();
114                 if (mLandscapeMode != newLandscapeMode) {
115                     mLandscapeMode = newLandscapeMode;
116                     if (mExpanded) {
117                         setExpanded(mExpanded, false /* animate */, mViewPager.getCurrentItem(),
118                                 true /* force */);
119                     }
120                 }
121             }
122         });
123     }
124 
125     @Override
onMeasure(final int widthMeasureSpec, final int heightMeasureSpec)126     protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
127         int requestedHeight = MeasureSpec.getSize(heightMeasureSpec);
128         if (mMediaPicker.getChooserShowsActionBarInFullScreen()) {
129             requestedHeight -= mActionBarHeight;
130         }
131         int desiredHeight = Math.min(mCurrentDesiredHeight, requestedHeight);
132         if (mExpanded && desiredHeight == 0) {
133             // If we want to be shown, we have to have a non-0 height.  Returning a height of 0 will
134             // cause the framework to abort the animation from 0, so we must always have some
135             // height once we start expanding
136             desiredHeight = 1;
137         } else if (!mExpanded && desiredHeight == 0) {
138             mViewPager.setVisibility(View.GONE);
139             mViewPager.setAdapter(null);
140         }
141 
142         measureChild(mTabStrip, widthMeasureSpec, heightMeasureSpec);
143 
144         int tabStripHeight;
145         if (requiresFullScreen()) {
146             // Ensure that the tab strip is always visible, even in full screen.
147             tabStripHeight = mTabStrip.getMeasuredHeight();
148         } else {
149             // Slide out the tab strip at the end of the animation to full screen.
150             tabStripHeight = Math.min(mTabStrip.getMeasuredHeight(),
151                     requestedHeight - desiredHeight);
152         }
153 
154         // If we are animating and have an interim desired height, use the default height. We can't
155         // take the max here as on some devices the mDefaultViewPagerHeight may be too big in
156         // landscape mode after animation.
157         final int tabAdjustedDesiredHeight = desiredHeight - tabStripHeight;
158         final int viewPagerHeight =
159                 tabAdjustedDesiredHeight <= 1 ? mDefaultViewPagerHeight : tabAdjustedDesiredHeight;
160 
161         int viewPagerHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
162                 viewPagerHeight, MeasureSpec.EXACTLY);
163         measureChild(mViewPager, widthMeasureSpec, viewPagerHeightMeasureSpec);
164         setMeasuredDimension(mViewPager.getMeasuredWidth(), desiredHeight);
165     }
166 
167     @Override
onLayout(final boolean changed, final int left, final int top, final int right, final int bottom)168     protected void onLayout(final boolean changed, final int left, final int top, final int right,
169             final int bottom) {
170         int y = top;
171         final int width = right - left;
172 
173         final int viewPagerHeight = mViewPager.getMeasuredHeight();
174         mViewPager.layout(0, y, width, y + viewPagerHeight);
175         y += viewPagerHeight;
176 
177         mTabStrip.layout(0, y, width, y + mTabStrip.getMeasuredHeight());
178     }
179 
onChooserChanged()180     void onChooserChanged() {
181         if (mFullScreen) {
182             setDesiredHeight(getDesiredHeight(), true);
183         }
184     }
185 
setFullScreenOnly(boolean fullScreenOnly)186     void setFullScreenOnly(boolean fullScreenOnly) {
187         mFullScreenOnly = fullScreenOnly;
188     }
189 
isFullScreen()190     boolean isFullScreen() {
191         return mFullScreen;
192     }
193 
setMediaPicker(final MediaPicker mediaPicker)194     void setMediaPicker(final MediaPicker mediaPicker) {
195         mMediaPicker = mediaPicker;
196     }
197 
198     /**
199      * Get the desired height of the media picker panel for when the panel is not in motion (i.e.
200      * not being dragged by the user).
201      */
getDesiredHeight()202     private int getDesiredHeight() {
203         if (mFullScreen) {
204             int fullHeight = getContext().getResources().getDisplayMetrics().heightPixels;
205             if (OsUtil.isAtLeastKLP() && isAttachedToWindow()) {
206                 // When we're attached to the window, we can get an accurate height, not necessary
207                 // on older API level devices because they don't include the action bar height
208                 View composeContainer =
209                         getRootView().findViewById(R.id.conversation_and_compose_container);
210                 if (composeContainer != null) {
211                     // protect against composeContainer having been unloaded already
212                     fullHeight -= UiUtils.getMeasuredBoundsOnScreen(composeContainer).top;
213                 }
214             }
215             if (mMediaPicker.getChooserShowsActionBarInFullScreen()) {
216                 return fullHeight - mActionBarHeight;
217             } else {
218                 return fullHeight;
219             }
220         } else if (mExpanded) {
221             return LayoutParams.WRAP_CONTENT;
222         } else {
223             return 0;
224         }
225     }
226 
setupViewPager(final int startingPage)227     private void setupViewPager(final int startingPage) {
228         mViewPager.setVisibility(View.VISIBLE);
229         if (startingPage >= 0 && startingPage < mMediaPicker.getPagerAdapter().getCount()) {
230             mViewPager.setAdapter(mMediaPicker.getPagerAdapter());
231             mViewPager.setCurrentItem(startingPage);
232         }
233         updateViewPager();
234     }
235 
236     /**
237      * Expand the media picker panel. Since we always set the pager adapter to null when the panel
238      * is collapsed, we need to restore the adapter and the starting page.
239      * @param expanded expanded or collapsed
240      * @param animate need animation
241      * @param startingPage the desired selected page to start
242      */
setExpanded(final boolean expanded, final boolean animate, final int startingPage)243     void setExpanded(final boolean expanded, final boolean animate, final int startingPage) {
244         setExpanded(expanded, animate, startingPage, false /* force */);
245     }
246 
setExpanded(final boolean expanded, final boolean animate, final int startingPage, final boolean force)247     private void setExpanded(final boolean expanded, final boolean animate, final int startingPage,
248             final boolean force) {
249         if (expanded == mExpanded && !force) {
250             return;
251         }
252         mFullScreen = false;
253         mExpanded = expanded;
254         mHandler.post(new Runnable() {
255             @Override
256             public void run() {
257                 setDesiredHeight(getDesiredHeight(), animate);
258             }
259         });
260         if (expanded) {
261             setupViewPager(startingPage);
262             mMediaPicker.dispatchOpened();
263         } else {
264             mMediaPicker.dispatchDismissed();
265         }
266 
267         // Call setFullScreenView() when we are in landscape mode so it can go full screen as
268         // soon as it is expanded.
269         if (expanded && requiresFullScreen()) {
270             setFullScreenView(true, animate);
271         }
272     }
273 
requiresFullScreen()274     private boolean requiresFullScreen() {
275         return mFullScreenOnly || UiUtils.isLandscapeMode();
276     }
277 
setDesiredHeight(int height, final boolean animate)278     private void setDesiredHeight(int height, final boolean animate) {
279         final int startHeight = mCurrentDesiredHeight;
280         if (height == LayoutParams.WRAP_CONTENT) {
281             height = measureHeight();
282         }
283         clearAnimation();
284         if (animate) {
285             final int deltaHeight = height - startHeight;
286             final Animation animation = new Animation() {
287                 @Override
288                 protected void applyTransformation(final float interpolatedTime,
289                         final Transformation t) {
290                     mCurrentDesiredHeight = (int) (startHeight + deltaHeight * interpolatedTime);
291                     requestLayout();
292                 }
293 
294                 @Override
295                 public boolean willChangeBounds() {
296                     return true;
297                 }
298             };
299             animation.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION);
300             animation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR);
301             startAnimation(animation);
302         } else {
303             mCurrentDesiredHeight = height;
304         }
305         requestLayout();
306     }
307 
308     /**
309      * @return The minimum total height of the view
310      */
measureHeight()311     private int measureHeight() {
312         final int measureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE, MeasureSpec.AT_MOST);
313         measureChild(mTabStrip, measureSpec, measureSpec);
314         return mDefaultViewPagerHeight + mTabStrip.getMeasuredHeight();
315     }
316 
317     /**
318      * Enters or leaves full screen view
319      *
320      * @param fullScreen True to enter full screen view, false to leave
321      * @param animate    True to animate the transition
322      */
setFullScreenView(final boolean fullScreen, final boolean animate)323     void setFullScreenView(final boolean fullScreen, final boolean animate) {
324         if (fullScreen == mFullScreen) {
325             return;
326         }
327 
328         if (requiresFullScreen() && !fullScreen) {
329             setExpanded(false /* expanded */, true /* animate */, PAGE_NOT_SET);
330             return;
331         }
332         mFullScreen = fullScreen;
333         setDesiredHeight(getDesiredHeight(), animate);
334         mMediaPicker.dispatchFullScreen(mFullScreen);
335         updateViewPager();
336     }
337 
338     /**
339      * ViewPager should have its paging disabled when in full screen mode.
340      */
updateViewPager()341     private void updateViewPager() {
342         mViewPager.setPagingEnabled(!mFullScreen);
343     }
344 
345     @Override
onInterceptTouchEvent(final MotionEvent ev)346     public boolean onInterceptTouchEvent(final MotionEvent ev) {
347         return mTouchHandler.onInterceptTouchEvent(ev) || super.onInterceptTouchEvent(ev);
348     }
349 
350     /**
351      * Helper class to handle touch events and swipe gestures
352      */
353     private class TouchHandler implements OnTouchListener {
354         /**
355          * The height of the view when the touch press started
356          */
357         private int mDownHeight = -1;
358 
359         /**
360          * True if the panel moved at all (changed height) during the drag
361          */
362         private boolean mMoved = false;
363 
364         // The threshold constants converted from DP to px
365         private final float mFlingThresholdPx;
366         private final float mBigFlingThresholdPx;
367 
368         // The system defined pixel size to determine when a movement is considered a drag.
369         private final int mTouchSlop;
370 
371         /**
372          * A copy of the MotionEvent that started the drag/swipe gesture
373          */
374         private MotionEvent mDownEvent;
375 
376         /**
377          * Whether we are currently moving down. We may not be able to move down in full screen
378          * mode when the child view can swipe down (such as a list view).
379          */
380         private boolean mMovedDown = false;
381 
382         /**
383          * Indicates whether the child view contained in the panel can swipe down at the beginning
384          * of the drag event (i.e. the initial down). The MediaPanel can contain
385          * scrollable children such as a list view / grid view. If the child view can swipe down,
386          * We want to let the child view handle the scroll first instead of handling it ourselves.
387          */
388         private boolean mCanChildViewSwipeDown = false;
389 
390         /**
391          * Necessary direction ratio for a fling to be considered in one direction this prevents
392          * horizontal swipes with small vertical components from triggering vertical swipe actions
393          */
394         private static final float DIRECTION_RATIO = 1.1f;
395 
TouchHandler()396         TouchHandler() {
397             final Resources resources = getContext().getResources();
398             final ViewConfiguration configuration = ViewConfiguration.get(getContext());
399             mFlingThresholdPx = resources.getDimensionPixelSize(
400                     R.dimen.mediapicker_fling_threshold);
401             mBigFlingThresholdPx = resources.getDimensionPixelSize(
402                     R.dimen.mediapicker_big_fling_threshold);
403             mTouchSlop = configuration.getScaledTouchSlop();
404         }
405 
406         /**
407          * The media picker panel may contain scrollable children such as a GridView, which eats
408          * all touch events before we get to it. Therefore, we'd like to intercept these events
409          * before the children to determine if we should handle swiping down in full screen mode.
410          * In non-full screen mode, we should handle all vertical scrolling events and leave
411          * horizontal scrolling to the view pager.
412          */
onInterceptTouchEvent(final MotionEvent ev)413         public boolean onInterceptTouchEvent(final MotionEvent ev) {
414             switch (ev.getActionMasked()) {
415                 case MotionEvent.ACTION_DOWN:
416                     // Never capture the initial down, so that the children may handle it
417                     // as well. Let the touch handler know about the down event as well.
418                     mTouchHandler.onTouch(MediaPickerPanel.this, ev);
419 
420                     // Ask the MediaPicker whether the contained view can be swiped down.
421                     // We record the value at the start of the drag to decide the swiping mode
422                     // for the entire motion.
423                     mCanChildViewSwipeDown = mMediaPicker.canSwipeDownChooser();
424                     return false;
425 
426                 case MotionEvent.ACTION_MOVE: {
427                     if (mMediaPicker.isChooserHandlingTouch()) {
428                         if (shouldAllowRecaptureTouch(ev)) {
429                             mMediaPicker.stopChooserTouchHandling();
430                             mViewPager.setPagingEnabled(true);
431                             return false;
432                         }
433                         // If the chooser is claiming ownership on all touch events, then we
434                         // shouldn't try to handle them (neither should the view pager).
435                         mViewPager.setPagingEnabled(false);
436                         return false;
437                     } else if (mCanChildViewSwipeDown) {
438                         // Never capture event if the child view can swipe down.
439                         return false;
440                     } else if (!mFullScreen && mMoved) {
441                         // When we are not fullscreen, we own any vertical drag motion.
442                         return true;
443                     } else if (mMovedDown) {
444                         // We are currently handling the down swipe ourselves, so always
445                         // capture this event.
446                         return true;
447                     } else {
448                         // The current interaction mode is undetermined, so always let the
449                         // touch handler know about this event. However, don't capture this
450                         // event so the child may handle it as well.
451                         mTouchHandler.onTouch(MediaPickerPanel.this, ev);
452 
453                         // Capture the touch event from now on if we are handling the drag.
454                         return mFullScreen ? mMovedDown : mMoved;
455                     }
456                 }
457             }
458             return false;
459         }
460 
461         /**
462          * Determine whether we think the user is actually trying to expand or slide despite the
463          * fact that they touched first on a chooser that captured the input.
464          */
shouldAllowRecaptureTouch(MotionEvent ev)465         private boolean shouldAllowRecaptureTouch(MotionEvent ev) {
466             final long elapsedMs = ev.getEventTime() - ev.getDownTime();
467             if (mDownEvent == null || elapsedMs == 0 || elapsedMs > TOUCH_RECAPTURE_WINDOW_MS) {
468                 // Either we don't have info to decide or it's been long enough that we no longer
469                 // want to reinterpret user intent.
470                 return false;
471             }
472             final float dx = ev.getRawX() - mDownEvent.getRawX();
473             final float dy = ev.getRawY() - mDownEvent.getRawY();
474             final float dt = elapsedMs / 1000.0f;
475             final float maxAbsDelta = Math.max(Math.abs(dx), Math.abs(dy));
476             final float velocity = maxAbsDelta / dt;
477             return velocity > mFlingThresholdPx;
478         }
479 
480         @Override
onTouch(final View view, final MotionEvent motionEvent)481         public boolean onTouch(final View view, final MotionEvent motionEvent) {
482             switch (motionEvent.getAction()) {
483                 case MotionEvent.ACTION_UP: {
484                     if (!mMoved || mDownEvent == null) {
485                         return false;
486                     }
487                     final float dx = motionEvent.getRawX() - mDownEvent.getRawX();
488                     final float dy = motionEvent.getRawY() - mDownEvent.getRawY();
489 
490                     final float dt =
491                             (motionEvent.getEventTime() - mDownEvent.getEventTime()) / 1000.0f;
492                     final float yVelocity = dy / dt;
493 
494                     boolean handled = false;
495 
496                     // Vertical swipe occurred if the direction is as least mostly in the y
497                     // component and has the required velocity (px/sec)
498                     if ((dx == 0 || (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) &&
499                             Math.abs(yVelocity) > mFlingThresholdPx) {
500                         if (yVelocity < 0 && mExpanded) {
501                             setFullScreenView(true, true);
502                             handled = true;
503                         } else if (yVelocity > 0) {
504                             if (mFullScreen && yVelocity < mBigFlingThresholdPx) {
505                                 setFullScreenView(false, true);
506                             } else {
507                                 setExpanded(false, true, PAGE_NOT_SET);
508                             }
509                             handled = true;
510                         }
511                     }
512 
513                     if (!handled) {
514                         // If they didn't swipe enough, animate back to resting state
515                         setDesiredHeight(getDesiredHeight(), true);
516                     }
517                     resetState();
518                     break;
519                 }
520                 case MotionEvent.ACTION_DOWN: {
521                     mDownHeight = getHeight();
522                     mDownEvent = MotionEvent.obtain(motionEvent);
523                     // If we are here and care about the return value (i.e. this is not called
524                     // from onInterceptTouchEvent), then presumably no children view in the panel
525                     // handles the down event. We'd like to handle future ACTION_MOVE events, so
526                     // always claim ownership on this event so it doesn't fall through and gets
527                     // cancelled by the framework.
528                     return true;
529                 }
530                 case MotionEvent.ACTION_MOVE: {
531                     if (mDownEvent == null) {
532                         return mMoved;
533                     }
534 
535                     final float dx = mDownEvent.getRawX() - motionEvent.getRawX();
536                     final float dy = mDownEvent.getRawY() - motionEvent.getRawY();
537                     // Don't act if the move is mostly horizontal
538                     if (Math.abs(dy) > mTouchSlop &&
539                             (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) {
540                         setDesiredHeight((int) (mDownHeight + dy), false);
541                         mMoved = true;
542                         if (dy < -mTouchSlop) {
543                             mMovedDown = true;
544                         }
545                     }
546                     return mMoved;
547                 }
548 
549             }
550             return mMoved;
551         }
552 
resetState()553         private void resetState() {
554             mDownEvent = null;
555             mDownHeight = -1;
556             mMoved = false;
557             mMovedDown = false;
558             mCanChildViewSwipeDown = false;
559             updateViewPager();
560         }
561     }
562 }
563 
564