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.conversation; 18 19 import android.animation.AnimatorSet; 20 import android.animation.ObjectAnimator; 21 import android.content.Context; 22 import android.content.res.Resources; 23 import android.graphics.Rect; 24 import android.graphics.drawable.StateListDrawable; 25 import android.os.Handler; 26 import android.support.v7.widget.LinearLayoutManager; 27 import android.support.v7.widget.RecyclerView; 28 import android.support.v7.widget.RecyclerView.AdapterDataObserver; 29 import android.support.v7.widget.RecyclerView.ViewHolder; 30 import android.util.StateSet; 31 import android.view.LayoutInflater; 32 import android.view.MotionEvent; 33 import android.view.View; 34 import android.view.View.MeasureSpec; 35 import android.view.View.OnLayoutChangeListener; 36 import android.view.ViewGroupOverlay; 37 import android.widget.ImageView; 38 import android.widget.TextView; 39 40 import com.android.messaging.R; 41 import com.android.messaging.datamodel.data.ConversationMessageData; 42 import com.android.messaging.ui.ConversationDrawables; 43 import com.android.messaging.util.Dates; 44 import com.android.messaging.util.OsUtil; 45 46 /** 47 * Adds a "fast-scroll" bar to the conversation RecyclerView that shows the current position within 48 * the conversation and allows quickly moving to another position by dragging the scrollbar thumb 49 * up or down. As the thumb is dragged, we show a floating bubble alongside it that shows the 50 * date/time of the first visible message at the current position. 51 */ 52 public class ConversationFastScroller extends RecyclerView.OnScrollListener implements 53 OnLayoutChangeListener, RecyclerView.OnItemTouchListener { 54 55 /** 56 * Creates a {@link ConversationFastScroller} instance, attached to the provided 57 * {@link RecyclerView}. 58 * 59 * @param rv the conversation RecyclerView 60 * @param position where the scrollbar should appear (either {@code POSITION_RIGHT_SIDE} or 61 * {@code POSITION_LEFT_SIDE}) 62 * @return a new ConversationFastScroller, or {@code null} if fast-scrolling is not supported 63 * (the feature requires Jellybean MR2 or newer) 64 */ addTo(RecyclerView rv, int position)65 public static ConversationFastScroller addTo(RecyclerView rv, int position) { 66 if (OsUtil.isAtLeastJB_MR2()) { 67 return new ConversationFastScroller(rv, position); 68 } 69 return null; 70 } 71 72 public static final int POSITION_RIGHT_SIDE = 0; 73 public static final int POSITION_LEFT_SIDE = 1; 74 75 private static final int MIN_PAGES_TO_ENABLE = 7; 76 private static final int SHOW_ANIMATION_DURATION_MS = 150; 77 private static final int HIDE_ANIMATION_DURATION_MS = 300; 78 private static final int HIDE_DELAY_MS = 1500; 79 80 private final Context mContext; 81 private final RecyclerView mRv; 82 private final ViewGroupOverlay mOverlay; 83 private final ImageView mTrackImageView; 84 private final ImageView mThumbImageView; 85 private final TextView mPreviewTextView; 86 87 private final int mTrackWidth; 88 private final int mThumbHeight; 89 private final int mPreviewHeight; 90 private final int mPreviewMinWidth; 91 private final int mPreviewMarginTop; 92 private final int mPreviewMarginLeftRight; 93 private final int mTouchSlop; 94 95 private final Rect mContainer = new Rect(); 96 private final Handler mHandler = new Handler(); 97 98 // Whether to render the scrollbar on the right side (otherwise it'll be on the left). 99 private final boolean mPosRight; 100 101 // Whether the scrollbar is currently visible (it may still be animating). 102 private boolean mVisible = false; 103 104 // Whether we are waiting to hide the scrollbar (i.e. scrolling has stopped). 105 private boolean mPendingHide = false; 106 107 // Whether the user is currently dragging the thumb up or down. 108 private boolean mDragging = false; 109 110 // Animations responsible for hiding the scrollbar & preview. May be null. 111 private AnimatorSet mHideAnimation; 112 private ObjectAnimator mHidePreviewAnimation; 113 114 private final Runnable mHideTrackRunnable = new Runnable() { 115 @Override 116 public void run() { 117 hide(true /* animate */); 118 mPendingHide = false; 119 } 120 }; 121 ConversationFastScroller(RecyclerView rv, int position)122 private ConversationFastScroller(RecyclerView rv, int position) { 123 mContext = rv.getContext(); 124 mRv = rv; 125 mRv.addOnLayoutChangeListener(this); 126 mRv.addOnScrollListener(this); 127 mRv.addOnItemTouchListener(this); 128 mRv.getAdapter().registerAdapterDataObserver(new AdapterDataObserver() { 129 @Override 130 public void onChanged() { 131 updateScrollPos(); 132 } 133 }); 134 mPosRight = (position == POSITION_RIGHT_SIDE); 135 136 // Cache the dimensions we'll need during layout 137 final Resources res = mContext.getResources(); 138 mTrackWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_width); 139 mThumbHeight = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_height); 140 mPreviewHeight = res.getDimensionPixelSize(R.dimen.fastscroll_preview_height); 141 mPreviewMinWidth = res.getDimensionPixelSize(R.dimen.fastscroll_preview_min_width); 142 mPreviewMarginTop = res.getDimensionPixelOffset(R.dimen.fastscroll_preview_margin_top); 143 mPreviewMarginLeftRight = res.getDimensionPixelOffset( 144 R.dimen.fastscroll_preview_margin_left_right); 145 mTouchSlop = res.getDimensionPixelOffset(R.dimen.fastscroll_touch_slop); 146 147 final LayoutInflater inflator = LayoutInflater.from(mContext); 148 mTrackImageView = (ImageView) inflator.inflate(R.layout.fastscroll_track, null); 149 mThumbImageView = (ImageView) inflator.inflate(R.layout.fastscroll_thumb, null); 150 mPreviewTextView = (TextView) inflator.inflate(R.layout.fastscroll_preview, null); 151 152 refreshConversationThemeColor(); 153 154 // Add the fast scroll views to the overlay, so they are rendered above the list 155 mOverlay = rv.getOverlay(); 156 mOverlay.add(mTrackImageView); 157 mOverlay.add(mThumbImageView); 158 mOverlay.add(mPreviewTextView); 159 160 hide(false /* animate */); 161 mPreviewTextView.setAlpha(0f); 162 } 163 refreshConversationThemeColor()164 public void refreshConversationThemeColor() { 165 mPreviewTextView.setBackground( 166 ConversationDrawables.get().getFastScrollPreviewDrawable(mPosRight)); 167 if (OsUtil.isAtLeastL()) { 168 final StateListDrawable drawable = new StateListDrawable(); 169 drawable.addState(new int[]{ android.R.attr.state_pressed }, 170 ConversationDrawables.get().getFastScrollThumbDrawable(true /* pressed */)); 171 drawable.addState(StateSet.WILD_CARD, 172 ConversationDrawables.get().getFastScrollThumbDrawable(false /* pressed */)); 173 mThumbImageView.setImageDrawable(drawable); 174 } else { 175 // Android pre-L doesn't seem to handle a StateListDrawable containing a tinted 176 // drawable (it's rendered in the filter base color, which is red), so fall back to 177 // just the regular (non-pressed) drawable. 178 mThumbImageView.setImageDrawable( 179 ConversationDrawables.get().getFastScrollThumbDrawable(false /* pressed */)); 180 } 181 } 182 183 @Override onScrollStateChanged(final RecyclerView view, final int newState)184 public void onScrollStateChanged(final RecyclerView view, final int newState) { 185 if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { 186 // Only show the scrollbar once the user starts scrolling 187 if (!mVisible && isEnabled()) { 188 show(); 189 } 190 cancelAnyPendingHide(); 191 } else if (newState == RecyclerView.SCROLL_STATE_IDLE && !mDragging) { 192 // Hide the scrollbar again after scrolling stops 193 hideAfterDelay(); 194 } 195 } 196 isEnabled()197 private boolean isEnabled() { 198 final int range = mRv.computeVerticalScrollRange(); 199 final int extent = mRv.computeVerticalScrollExtent(); 200 201 if (range == 0 || extent == 0) { 202 return false; // Conversation isn't long enough to scroll 203 } 204 // Only enable scrollbars for conversations long enough that they would require several 205 // flings to scroll through. 206 final float pages = (float) range / extent; 207 return (pages > MIN_PAGES_TO_ENABLE); 208 } 209 show()210 private void show() { 211 if (mHideAnimation != null && mHideAnimation.isRunning()) { 212 mHideAnimation.cancel(); 213 } 214 // Slide the scrollbar in from the side 215 ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X, 0); 216 ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X, 0); 217 AnimatorSet animation = new AnimatorSet(); 218 animation.playTogether(trackSlide, thumbSlide); 219 animation.setDuration(SHOW_ANIMATION_DURATION_MS); 220 animation.start(); 221 222 mVisible = true; 223 updateScrollPos(); 224 } 225 hideAfterDelay()226 private void hideAfterDelay() { 227 cancelAnyPendingHide(); 228 mHandler.postDelayed(mHideTrackRunnable, HIDE_DELAY_MS); 229 mPendingHide = true; 230 } 231 cancelAnyPendingHide()232 private void cancelAnyPendingHide() { 233 if (mPendingHide) { 234 mHandler.removeCallbacks(mHideTrackRunnable); 235 } 236 } 237 hide(boolean animate)238 private void hide(boolean animate) { 239 final int hiddenTranslationX = mPosRight ? mTrackWidth : -mTrackWidth; 240 if (animate) { 241 // Slide the scrollbar off to the side 242 ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X, 243 hiddenTranslationX); 244 ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X, 245 hiddenTranslationX); 246 mHideAnimation = new AnimatorSet(); 247 mHideAnimation.playTogether(trackSlide, thumbSlide); 248 mHideAnimation.setDuration(HIDE_ANIMATION_DURATION_MS); 249 mHideAnimation.start(); 250 } else { 251 mTrackImageView.setTranslationX(hiddenTranslationX); 252 mThumbImageView.setTranslationX(hiddenTranslationX); 253 } 254 255 mVisible = false; 256 } 257 showPreview()258 private void showPreview() { 259 if (mHidePreviewAnimation != null && mHidePreviewAnimation.isRunning()) { 260 mHidePreviewAnimation.cancel(); 261 } 262 mPreviewTextView.setAlpha(1f); 263 } 264 hidePreview()265 private void hidePreview() { 266 mHidePreviewAnimation = ObjectAnimator.ofFloat(mPreviewTextView, View.ALPHA, 0f); 267 mHidePreviewAnimation.setDuration(HIDE_ANIMATION_DURATION_MS); 268 mHidePreviewAnimation.start(); 269 } 270 271 @Override onScrolled(final RecyclerView view, final int dx, final int dy)272 public void onScrolled(final RecyclerView view, final int dx, final int dy) { 273 updateScrollPos(); 274 } 275 updateScrollPos()276 private void updateScrollPos() { 277 if (!mVisible) { 278 return; 279 } 280 final int verticalScrollLength = mContainer.height() - mThumbHeight; 281 final int verticalScrollStart = mContainer.top + mThumbHeight / 2; 282 283 final float scrollRatio = computeScrollRatio(); 284 final int thumbCenterY = verticalScrollStart + (int)(verticalScrollLength * scrollRatio); 285 layoutThumb(thumbCenterY); 286 287 if (mDragging) { 288 updatePreviewText(); 289 layoutPreview(thumbCenterY); 290 } 291 } 292 293 /** 294 * Returns the current position in the conversation, as a value between 0 and 1, inclusive. 295 * The top of the conversation is 0, the bottom is 1, the exact middle is 0.5, and so on. 296 */ computeScrollRatio()297 private float computeScrollRatio() { 298 final int range = mRv.computeVerticalScrollRange(); 299 final int extent = mRv.computeVerticalScrollExtent(); 300 int offset = mRv.computeVerticalScrollOffset(); 301 302 if (range == 0 || extent == 0) { 303 // If the conversation doesn't scroll, we're at the bottom. 304 return 1.0f; 305 } 306 final int scrollRange = range - extent; 307 offset = Math.min(offset, scrollRange); 308 return offset / (float) scrollRange; 309 } 310 updatePreviewText()311 private void updatePreviewText() { 312 final LinearLayoutManager lm = (LinearLayoutManager) mRv.getLayoutManager(); 313 final int pos = lm.findFirstVisibleItemPosition(); 314 if (pos == RecyclerView.NO_POSITION) { 315 return; 316 } 317 final ViewHolder vh = mRv.findViewHolderForAdapterPosition(pos); 318 if (vh == null) { 319 // This can happen if the messages update while we're dragging the thumb. 320 return; 321 } 322 final ConversationMessageView messageView = (ConversationMessageView) vh.itemView; 323 final ConversationMessageData messageData = messageView.getData(); 324 final long timestamp = messageData.getReceivedTimeStamp(); 325 final CharSequence timestampText = Dates.getFastScrollPreviewTimeString(timestamp); 326 mPreviewTextView.setText(timestampText); 327 } 328 329 @Override onInterceptTouchEvent(RecyclerView rv, MotionEvent e)330 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) { 331 if (!mVisible) { 332 return false; 333 } 334 // If the user presses down on the scroll thumb, we'll start intercepting events from the 335 // RecyclerView so we can handle the move events while they're dragging it up/down. 336 final int action = e.getActionMasked(); 337 switch (action) { 338 case MotionEvent.ACTION_DOWN: 339 if (isInsideThumb(e.getX(), e.getY())) { 340 startDrag(); 341 return true; 342 } 343 break; 344 case MotionEvent.ACTION_MOVE: 345 if (mDragging) { 346 return true; 347 } 348 case MotionEvent.ACTION_CANCEL: 349 case MotionEvent.ACTION_UP: 350 if (mDragging) { 351 cancelDrag(); 352 } 353 return false; 354 } 355 return false; 356 } 357 isInsideThumb(float x, float y)358 private boolean isInsideThumb(float x, float y) { 359 final int hitTargetLeft = mThumbImageView.getLeft() - mTouchSlop; 360 final int hitTargetRight = mThumbImageView.getRight() + mTouchSlop; 361 362 if (x < hitTargetLeft || x > hitTargetRight) { 363 return false; 364 } 365 if (y < mThumbImageView.getTop() || y > mThumbImageView.getBottom()) { 366 return false; 367 } 368 return true; 369 } 370 371 @Override onTouchEvent(RecyclerView rv, MotionEvent e)372 public void onTouchEvent(RecyclerView rv, MotionEvent e) { 373 if (!mDragging) { 374 return; 375 } 376 final int action = e.getActionMasked(); 377 switch (action) { 378 case MotionEvent.ACTION_MOVE: 379 handleDragMove(e.getY()); 380 break; 381 case MotionEvent.ACTION_CANCEL: 382 case MotionEvent.ACTION_UP: 383 cancelDrag(); 384 break; 385 } 386 } 387 388 @Override onRequestDisallowInterceptTouchEvent(boolean disallowIntercept)389 public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { 390 } 391 startDrag()392 private void startDrag() { 393 mDragging = true; 394 mThumbImageView.setPressed(true); 395 updateScrollPos(); 396 showPreview(); 397 cancelAnyPendingHide(); 398 } 399 handleDragMove(float y)400 private void handleDragMove(float y) { 401 final int verticalScrollLength = mContainer.height() - mThumbHeight; 402 final int verticalScrollStart = mContainer.top + (mThumbHeight / 2); 403 404 // Convert the desired position from px to a scroll position in the conversation. 405 float dragScrollRatio = (y - verticalScrollStart) / verticalScrollLength; 406 dragScrollRatio = Math.max(dragScrollRatio, 0.0f); 407 dragScrollRatio = Math.min(dragScrollRatio, 1.0f); 408 409 // Scroll the RecyclerView to a new position. 410 final int itemCount = mRv.getAdapter().getItemCount(); 411 final int itemPos = (int)((itemCount - 1) * dragScrollRatio); 412 mRv.scrollToPosition(itemPos); 413 } 414 cancelDrag()415 private void cancelDrag() { 416 mDragging = false; 417 mThumbImageView.setPressed(false); 418 hidePreview(); 419 hideAfterDelay(); 420 } 421 422 @Override onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom)423 public void onLayoutChange(View v, int left, int top, int right, int bottom, 424 int oldLeft, int oldTop, int oldRight, int oldBottom) { 425 if (!mVisible) { 426 hide(false /* animate */); 427 } 428 // The container is the size of the RecyclerView that's visible on screen. We have to 429 // exclude the top padding, because it's usually hidden behind the conversation action bar. 430 mContainer.set(left, top + mRv.getPaddingTop(), right, bottom); 431 layoutTrack(); 432 updateScrollPos(); 433 } 434 layoutTrack()435 private void layoutTrack() { 436 int trackHeight = Math.max(0, mContainer.height()); 437 int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY); 438 int heightMeasureSpec = MeasureSpec.makeMeasureSpec(trackHeight, MeasureSpec.EXACTLY); 439 mTrackImageView.measure(widthMeasureSpec, heightMeasureSpec); 440 441 int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left; 442 int top = mContainer.top; 443 int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth); 444 int bottom = mContainer.bottom; 445 mTrackImageView.layout(left, top, right, bottom); 446 } 447 layoutThumb(int centerY)448 private void layoutThumb(int centerY) { 449 int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY); 450 int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mThumbHeight, MeasureSpec.EXACTLY); 451 mThumbImageView.measure(widthMeasureSpec, heightMeasureSpec); 452 453 int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left; 454 int top = centerY - (mThumbImageView.getHeight() / 2); 455 int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth); 456 int bottom = top + mThumbHeight; 457 mThumbImageView.layout(left, top, right, bottom); 458 } 459 layoutPreview(int centerY)460 private void layoutPreview(int centerY) { 461 int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mContainer.width(), MeasureSpec.AT_MOST); 462 int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewHeight, MeasureSpec.EXACTLY); 463 mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec); 464 465 // Ensure that the preview bubble is at least as wide as it is tall 466 if (mPreviewTextView.getMeasuredWidth() < mPreviewMinWidth) { 467 widthMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewMinWidth, MeasureSpec.EXACTLY); 468 mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec); 469 } 470 final int previewMinY = mContainer.top + mPreviewMarginTop; 471 472 final int left, right; 473 if (mPosRight) { 474 right = mContainer.right - mTrackWidth - mPreviewMarginLeftRight; 475 left = right - mPreviewTextView.getMeasuredWidth(); 476 } else { 477 left = mContainer.left + mTrackWidth + mPreviewMarginLeftRight; 478 right = left + mPreviewTextView.getMeasuredWidth(); 479 } 480 481 int bottom = centerY; 482 int top = bottom - mPreviewTextView.getMeasuredHeight(); 483 if (top < previewMinY) { 484 top = previewMinY; 485 bottom = top + mPreviewTextView.getMeasuredHeight(); 486 } 487 mPreviewTextView.layout(left, top, right, bottom); 488 } 489 } 490