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