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