/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.messaging.ui.mediapicker; import android.content.Context; import android.content.res.Resources; import android.os.Handler; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.animation.Animation; import android.view.animation.Transformation; import android.widget.LinearLayout; import com.android.messaging.R; import com.android.messaging.ui.PagingAwareViewPager; import com.android.messaging.util.Assert; import com.android.messaging.util.OsUtil; import com.android.messaging.util.UiUtils; /** * Custom layout panel which makes the MediaPicker animations seamless and synchronized * Designed to be very specific to the MediaPicker's usage */ public class MediaPickerPanel extends ViewGroup { /** * The window of time in which we might to decide to reinterpret the intent of a gesture. */ private static final long TOUCH_RECAPTURE_WINDOW_MS = 500L; // The two view components to layout private LinearLayout mTabStrip; private boolean mFullScreenOnly; private PagingAwareViewPager mViewPager; /** * True if the MediaPicker is full screen or animating into it */ private boolean mFullScreen; /** * True if the MediaPicker is open at all */ private boolean mExpanded; /** * The current desired height of the MediaPicker. This property may be animated and the * measure pass uses it to determine what size the components are. */ private int mCurrentDesiredHeight; private final Handler mHandler = new Handler(); /** * The media picker for dispatching events to the MediaPicker's listener */ private MediaPicker mMediaPicker; /** * The computed default "half-screen" height of the view pager in px */ private final int mDefaultViewPagerHeight; /** * The action bar height used to compute the padding on the view pager when it's full screen. */ private final int mActionBarHeight; private TouchHandler mTouchHandler; static final int PAGE_NOT_SET = -1; public MediaPickerPanel(final Context context, final AttributeSet attrs) { super(context, attrs); // Cache the computed dimension mDefaultViewPagerHeight = getResources().getDimensionPixelSize( R.dimen.mediapicker_default_chooser_height); mActionBarHeight = getResources().getDimensionPixelSize(R.dimen.action_bar_height); } @Override protected void onFinishInflate() { super.onFinishInflate(); mTabStrip = (LinearLayout) findViewById(R.id.mediapicker_tabstrip); mViewPager = (PagingAwareViewPager) findViewById(R.id.mediapicker_view_pager); mTouchHandler = new TouchHandler(); setOnTouchListener(mTouchHandler); mViewPager.setOnTouchListener(mTouchHandler); // Make sure full screen mode is updated in landscape mode change when the panel is open. addOnLayoutChangeListener(new OnLayoutChangeListener() { private boolean mLandscapeMode = UiUtils.isLandscapeMode(); @Override public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { final boolean newLandscapeMode = UiUtils.isLandscapeMode(); if (mLandscapeMode != newLandscapeMode) { mLandscapeMode = newLandscapeMode; if (mExpanded) { setExpanded(mExpanded, false /* animate */, mViewPager.getCurrentItem(), true /* force */); } } } }); } @Override protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { int requestedHeight = MeasureSpec.getSize(heightMeasureSpec); if (mMediaPicker.getChooserShowsActionBarInFullScreen()) { requestedHeight -= mActionBarHeight; } int desiredHeight = Math.min(mCurrentDesiredHeight, requestedHeight); if (mExpanded && desiredHeight == 0) { // If we want to be shown, we have to have a non-0 height. Returning a height of 0 will // cause the framework to abort the animation from 0, so we must always have some // height once we start expanding desiredHeight = 1; } else if (!mExpanded && desiredHeight == 0) { mViewPager.setVisibility(View.GONE); mViewPager.setAdapter(null); } measureChild(mTabStrip, widthMeasureSpec, heightMeasureSpec); int tabStripHeight; if (requiresFullScreen()) { // Ensure that the tab strip is always visible, even in full screen. tabStripHeight = mTabStrip.getMeasuredHeight(); } else { // Slide out the tab strip at the end of the animation to full screen. tabStripHeight = Math.min(mTabStrip.getMeasuredHeight(), requestedHeight - desiredHeight); } // If we are animating and have an interim desired height, use the default height. We can't // take the max here as on some devices the mDefaultViewPagerHeight may be too big in // landscape mode after animation. final int tabAdjustedDesiredHeight = desiredHeight - tabStripHeight; final int viewPagerHeight = tabAdjustedDesiredHeight <= 1 ? mDefaultViewPagerHeight : tabAdjustedDesiredHeight; int viewPagerHeightMeasureSpec = MeasureSpec.makeMeasureSpec( viewPagerHeight, MeasureSpec.EXACTLY); measureChild(mViewPager, widthMeasureSpec, viewPagerHeightMeasureSpec); setMeasuredDimension(mViewPager.getMeasuredWidth(), desiredHeight); } @Override protected void onLayout(final boolean changed, final int left, final int top, final int right, final int bottom) { int y = top; final int width = right - left; final int viewPagerHeight = mViewPager.getMeasuredHeight(); mViewPager.layout(0, y, width, y + viewPagerHeight); y += viewPagerHeight; mTabStrip.layout(0, y, width, y + mTabStrip.getMeasuredHeight()); } void onChooserChanged() { if (mFullScreen) { setDesiredHeight(getDesiredHeight(), true); } } void setFullScreenOnly(boolean fullScreenOnly) { mFullScreenOnly = fullScreenOnly; } boolean isFullScreen() { return mFullScreen; } void setMediaPicker(final MediaPicker mediaPicker) { mMediaPicker = mediaPicker; } /** * Get the desired height of the media picker panel for when the panel is not in motion (i.e. * not being dragged by the user). */ private int getDesiredHeight() { if (mFullScreen) { int fullHeight = getContext().getResources().getDisplayMetrics().heightPixels; if (OsUtil.isAtLeastKLP() && isAttachedToWindow()) { // When we're attached to the window, we can get an accurate height, not necessary // on older API level devices because they don't include the action bar height View composeContainer = getRootView().findViewById(R.id.conversation_and_compose_container); if (composeContainer != null) { // protect against composeContainer having been unloaded already fullHeight -= UiUtils.getMeasuredBoundsOnScreen(composeContainer).top; } } if (mMediaPicker.getChooserShowsActionBarInFullScreen()) { return fullHeight - mActionBarHeight; } else { return fullHeight; } } else if (mExpanded) { return LayoutParams.WRAP_CONTENT; } else { return 0; } } private void setupViewPager(final int startingPage) { mViewPager.setVisibility(View.VISIBLE); if (startingPage >= 0 && startingPage < mMediaPicker.getPagerAdapter().getCount()) { mViewPager.setAdapter(mMediaPicker.getPagerAdapter()); mViewPager.setCurrentItem(startingPage); } updateViewPager(); } /** * Expand the media picker panel. Since we always set the pager adapter to null when the panel * is collapsed, we need to restore the adapter and the starting page. * @param expanded expanded or collapsed * @param animate need animation * @param startingPage the desired selected page to start */ void setExpanded(final boolean expanded, final boolean animate, final int startingPage) { setExpanded(expanded, animate, startingPage, false /* force */); } private void setExpanded(final boolean expanded, final boolean animate, final int startingPage, final boolean force) { if (expanded == mExpanded && !force) { return; } mFullScreen = false; mExpanded = expanded; mHandler.post(new Runnable() { @Override public void run() { setDesiredHeight(getDesiredHeight(), animate); } }); if (expanded) { setupViewPager(startingPage); mMediaPicker.dispatchOpened(); } else { mMediaPicker.dispatchDismissed(); } // Call setFullScreenView() when we are in landscape mode so it can go full screen as // soon as it is expanded. if (expanded && requiresFullScreen()) { setFullScreenView(true, animate); } } private boolean requiresFullScreen() { return mFullScreenOnly || UiUtils.isLandscapeMode(); } private void setDesiredHeight(int height, final boolean animate) { final int startHeight = mCurrentDesiredHeight; if (height == LayoutParams.WRAP_CONTENT) { height = measureHeight(); } clearAnimation(); if (animate) { final int deltaHeight = height - startHeight; final Animation animation = new Animation() { @Override protected void applyTransformation(final float interpolatedTime, final Transformation t) { mCurrentDesiredHeight = (int) (startHeight + deltaHeight * interpolatedTime); requestLayout(); } @Override public boolean willChangeBounds() { return true; } }; animation.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION); animation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR); startAnimation(animation); } else { mCurrentDesiredHeight = height; } requestLayout(); } /** * @return The minimum total height of the view */ private int measureHeight() { final int measureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE, MeasureSpec.AT_MOST); measureChild(mTabStrip, measureSpec, measureSpec); return mDefaultViewPagerHeight + mTabStrip.getMeasuredHeight(); } /** * Enters or leaves full screen view * * @param fullScreen True to enter full screen view, false to leave * @param animate True to animate the transition */ void setFullScreenView(final boolean fullScreen, final boolean animate) { if (fullScreen == mFullScreen) { return; } if (requiresFullScreen() && !fullScreen) { setExpanded(false /* expanded */, true /* animate */, PAGE_NOT_SET); return; } mFullScreen = fullScreen; setDesiredHeight(getDesiredHeight(), animate); mMediaPicker.dispatchFullScreen(mFullScreen); updateViewPager(); } /** * ViewPager should have its paging disabled when in full screen mode. */ private void updateViewPager() { mViewPager.setPagingEnabled(!mFullScreen); } @Override public boolean onInterceptTouchEvent(final MotionEvent ev) { return mTouchHandler.onInterceptTouchEvent(ev) || super.onInterceptTouchEvent(ev); } /** * Helper class to handle touch events and swipe gestures */ private class TouchHandler implements OnTouchListener { /** * The height of the view when the touch press started */ private int mDownHeight = -1; /** * True if the panel moved at all (changed height) during the drag */ private boolean mMoved = false; // The threshold constants converted from DP to px private final float mFlingThresholdPx; private final float mBigFlingThresholdPx; // The system defined pixel size to determine when a movement is considered a drag. private final int mTouchSlop; /** * A copy of the MotionEvent that started the drag/swipe gesture */ private MotionEvent mDownEvent; /** * Whether we are currently moving down. We may not be able to move down in full screen * mode when the child view can swipe down (such as a list view). */ private boolean mMovedDown = false; /** * Indicates whether the child view contained in the panel can swipe down at the beginning * of the drag event (i.e. the initial down). The MediaPanel can contain * scrollable children such as a list view / grid view. If the child view can swipe down, * We want to let the child view handle the scroll first instead of handling it ourselves. */ private boolean mCanChildViewSwipeDown = false; /** * Necessary direction ratio for a fling to be considered in one direction this prevents * horizontal swipes with small vertical components from triggering vertical swipe actions */ private static final float DIRECTION_RATIO = 1.1f; TouchHandler() { final Resources resources = getContext().getResources(); final ViewConfiguration configuration = ViewConfiguration.get(getContext()); mFlingThresholdPx = resources.getDimensionPixelSize( R.dimen.mediapicker_fling_threshold); mBigFlingThresholdPx = resources.getDimensionPixelSize( R.dimen.mediapicker_big_fling_threshold); mTouchSlop = configuration.getScaledTouchSlop(); } /** * The media picker panel may contain scrollable children such as a GridView, which eats * all touch events before we get to it. Therefore, we'd like to intercept these events * before the children to determine if we should handle swiping down in full screen mode. * In non-full screen mode, we should handle all vertical scrolling events and leave * horizontal scrolling to the view pager. */ public boolean onInterceptTouchEvent(final MotionEvent ev) { switch (ev.getActionMasked()) { case MotionEvent.ACTION_DOWN: // Never capture the initial down, so that the children may handle it // as well. Let the touch handler know about the down event as well. mTouchHandler.onTouch(MediaPickerPanel.this, ev); // Ask the MediaPicker whether the contained view can be swiped down. // We record the value at the start of the drag to decide the swiping mode // for the entire motion. mCanChildViewSwipeDown = mMediaPicker.canSwipeDownChooser(); return false; case MotionEvent.ACTION_MOVE: { if (mMediaPicker.isChooserHandlingTouch()) { if (shouldAllowRecaptureTouch(ev)) { mMediaPicker.stopChooserTouchHandling(); mViewPager.setPagingEnabled(true); return false; } // If the chooser is claiming ownership on all touch events, then we // shouldn't try to handle them (neither should the view pager). mViewPager.setPagingEnabled(false); return false; } else if (mCanChildViewSwipeDown) { // Never capture event if the child view can swipe down. return false; } else if (!mFullScreen && mMoved) { // When we are not fullscreen, we own any vertical drag motion. return true; } else if (mMovedDown) { // We are currently handling the down swipe ourselves, so always // capture this event. return true; } else { // The current interaction mode is undetermined, so always let the // touch handler know about this event. However, don't capture this // event so the child may handle it as well. mTouchHandler.onTouch(MediaPickerPanel.this, ev); // Capture the touch event from now on if we are handling the drag. return mFullScreen ? mMovedDown : mMoved; } } } return false; } /** * Determine whether we think the user is actually trying to expand or slide despite the * fact that they touched first on a chooser that captured the input. */ private boolean shouldAllowRecaptureTouch(MotionEvent ev) { final long elapsedMs = ev.getEventTime() - ev.getDownTime(); if (mDownEvent == null || elapsedMs == 0 || elapsedMs > TOUCH_RECAPTURE_WINDOW_MS) { // Either we don't have info to decide or it's been long enough that we no longer // want to reinterpret user intent. return false; } final float dx = ev.getRawX() - mDownEvent.getRawX(); final float dy = ev.getRawY() - mDownEvent.getRawY(); final float dt = elapsedMs / 1000.0f; final float maxAbsDelta = Math.max(Math.abs(dx), Math.abs(dy)); final float velocity = maxAbsDelta / dt; return velocity > mFlingThresholdPx; } @Override public boolean onTouch(final View view, final MotionEvent motionEvent) { switch (motionEvent.getAction()) { case MotionEvent.ACTION_UP: { if (!mMoved || mDownEvent == null) { return false; } final float dx = motionEvent.getRawX() - mDownEvent.getRawX(); final float dy = motionEvent.getRawY() - mDownEvent.getRawY(); final float dt = (motionEvent.getEventTime() - mDownEvent.getEventTime()) / 1000.0f; final float yVelocity = dy / dt; boolean handled = false; // Vertical swipe occurred if the direction is as least mostly in the y // component and has the required velocity (px/sec) if ((dx == 0 || (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) && Math.abs(yVelocity) > mFlingThresholdPx) { if (yVelocity < 0 && mExpanded) { setFullScreenView(true, true); handled = true; } else if (yVelocity > 0) { if (mFullScreen && yVelocity < mBigFlingThresholdPx) { setFullScreenView(false, true); } else { setExpanded(false, true, PAGE_NOT_SET); } handled = true; } } if (!handled) { // If they didn't swipe enough, animate back to resting state setDesiredHeight(getDesiredHeight(), true); } resetState(); break; } case MotionEvent.ACTION_DOWN: { mDownHeight = getHeight(); mDownEvent = MotionEvent.obtain(motionEvent); // If we are here and care about the return value (i.e. this is not called // from onInterceptTouchEvent), then presumably no children view in the panel // handles the down event. We'd like to handle future ACTION_MOVE events, so // always claim ownership on this event so it doesn't fall through and gets // cancelled by the framework. return true; } case MotionEvent.ACTION_MOVE: { if (mDownEvent == null) { return mMoved; } final float dx = mDownEvent.getRawX() - motionEvent.getRawX(); final float dy = mDownEvent.getRawY() - motionEvent.getRawY(); // Don't act if the move is mostly horizontal if (Math.abs(dy) > mTouchSlop && (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) { setDesiredHeight((int) (mDownHeight + dy), false); mMoved = true; if (dy < -mTouchSlop) { mMovedDown = true; } } return mMoved; } } return mMoved; } private void resetState() { mDownEvent = null; mDownHeight = -1; mMoved = false; mMovedDown = false; mCanChildViewSwipeDown = false; updateViewPager(); } } }