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 */ 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 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 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 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 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 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 226 private void hideAfterDelay() { 227 cancelAnyPendingHide(); 228 mHandler.postDelayed(mHideTrackRunnable, HIDE_DELAY_MS); 229 mPendingHide = true; 230 } 231 232 private void cancelAnyPendingHide() { 233 if (mPendingHide) { 234 mHandler.removeCallbacks(mHideTrackRunnable); 235 } 236 } 237 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 258 private void showPreview() { 259 if (mHidePreviewAnimation != null && mHidePreviewAnimation.isRunning()) { 260 mHidePreviewAnimation.cancel(); 261 } 262 mPreviewTextView.setAlpha(1f); 263 } 264 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 272 public void onScrolled(final RecyclerView view, final int dx, final int dy) { 273 updateScrollPos(); 274 } 275 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 */ 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 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 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 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 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 389 public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { 390 } 391 392 private void startDrag() { 393 mDragging = true; 394 mThumbImageView.setPressed(true); 395 updateScrollPos(); 396 showPreview(); 397 cancelAnyPendingHide(); 398 } 399 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 415 private void cancelDrag() { 416 mDragging = false; 417 mThumbImageView.setPressed(false); 418 hidePreview(); 419 hideAfterDelay(); 420 } 421 422 @Override 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 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 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 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