Home | History | Annotate | Download | only in mediapicker
      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 
     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
     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
    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
    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 
    180     void onChooserChanged() {
    181         if (mFullScreen) {
    182             setDesiredHeight(getDesiredHeight(), true);
    183         }
    184     }
    185 
    186     void setFullScreenOnly(boolean fullScreenOnly) {
    187         mFullScreenOnly = fullScreenOnly;
    188     }
    189 
    190     boolean isFullScreen() {
    191         return mFullScreen;
    192     }
    193 
    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      */
    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 
    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      */
    243     void setExpanded(final boolean expanded, final boolean animate, final int startingPage) {
    244         setExpanded(expanded, animate, startingPage, false /* force */);
    245     }
    246 
    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 
    274     private boolean requiresFullScreen() {
    275         return mFullScreenOnly || UiUtils.isLandscapeMode();
    276     }
    277 
    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      */
    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      */
    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      */
    341     private void updateViewPager() {
    342         mViewPager.setPagingEnabled(!mFullScreen);
    343     }
    344 
    345     @Override
    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 
    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          */
    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          */
    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
    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 
    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