diff options
Diffstat (limited to 'src/com/android/messaging/ui/conversation/ConversationFastScroller.java')
-rw-r--r-- | src/com/android/messaging/ui/conversation/ConversationFastScroller.java | 489 |
1 files changed, 489 insertions, 0 deletions
diff --git a/src/com/android/messaging/ui/conversation/ConversationFastScroller.java b/src/com/android/messaging/ui/conversation/ConversationFastScroller.java new file mode 100644 index 0000000..b15f05a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationFastScroller.java @@ -0,0 +1,489 @@ +/* + * 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.conversation; + +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Rect; +import android.graphics.drawable.StateListDrawable; +import android.os.Handler; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.RecyclerView.AdapterDataObserver; +import android.support.v7.widget.RecyclerView.ViewHolder; +import android.util.StateSet; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.View.OnLayoutChangeListener; +import android.view.ViewGroupOverlay; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.ui.ConversationDrawables; +import com.android.messaging.util.Dates; +import com.android.messaging.util.OsUtil; + +/** + * Adds a "fast-scroll" bar to the conversation RecyclerView that shows the current position within + * the conversation and allows quickly moving to another position by dragging the scrollbar thumb + * up or down. As the thumb is dragged, we show a floating bubble alongside it that shows the + * date/time of the first visible message at the current position. + */ +public class ConversationFastScroller extends RecyclerView.OnScrollListener implements + OnLayoutChangeListener, RecyclerView.OnItemTouchListener { + + /** + * Creates a {@link ConversationFastScroller} instance, attached to the provided + * {@link RecyclerView}. + * + * @param rv the conversation RecyclerView + * @param position where the scrollbar should appear (either {@code POSITION_RIGHT_SIDE} or + * {@code POSITION_LEFT_SIDE}) + * @return a new ConversationFastScroller, or {@code null} if fast-scrolling is not supported + * (the feature requires Jellybean MR2 or newer) + */ + public static ConversationFastScroller addTo(RecyclerView rv, int position) { + if (OsUtil.isAtLeastJB_MR2()) { + return new ConversationFastScroller(rv, position); + } + return null; + } + + public static final int POSITION_RIGHT_SIDE = 0; + public static final int POSITION_LEFT_SIDE = 1; + + private static final int MIN_PAGES_TO_ENABLE = 7; + private static final int SHOW_ANIMATION_DURATION_MS = 150; + private static final int HIDE_ANIMATION_DURATION_MS = 300; + private static final int HIDE_DELAY_MS = 1500; + + private final Context mContext; + private final RecyclerView mRv; + private final ViewGroupOverlay mOverlay; + private final ImageView mTrackImageView; + private final ImageView mThumbImageView; + private final TextView mPreviewTextView; + + private final int mTrackWidth; + private final int mThumbHeight; + private final int mPreviewHeight; + private final int mPreviewMinWidth; + private final int mPreviewMarginTop; + private final int mPreviewMarginLeftRight; + private final int mTouchSlop; + + private final Rect mContainer = new Rect(); + private final Handler mHandler = new Handler(); + + // Whether to render the scrollbar on the right side (otherwise it'll be on the left). + private final boolean mPosRight; + + // Whether the scrollbar is currently visible (it may still be animating). + private boolean mVisible = false; + + // Whether we are waiting to hide the scrollbar (i.e. scrolling has stopped). + private boolean mPendingHide = false; + + // Whether the user is currently dragging the thumb up or down. + private boolean mDragging = false; + + // Animations responsible for hiding the scrollbar & preview. May be null. + private AnimatorSet mHideAnimation; + private ObjectAnimator mHidePreviewAnimation; + + private final Runnable mHideTrackRunnable = new Runnable() { + @Override + public void run() { + hide(true /* animate */); + mPendingHide = false; + } + }; + + private ConversationFastScroller(RecyclerView rv, int position) { + mContext = rv.getContext(); + mRv = rv; + mRv.addOnLayoutChangeListener(this); + mRv.addOnScrollListener(this); + mRv.addOnItemTouchListener(this); + mRv.getAdapter().registerAdapterDataObserver(new AdapterDataObserver() { + @Override + public void onChanged() { + updateScrollPos(); + } + }); + mPosRight = (position == POSITION_RIGHT_SIDE); + + // Cache the dimensions we'll need during layout + final Resources res = mContext.getResources(); + mTrackWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_width); + mThumbHeight = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_height); + mPreviewHeight = res.getDimensionPixelSize(R.dimen.fastscroll_preview_height); + mPreviewMinWidth = res.getDimensionPixelSize(R.dimen.fastscroll_preview_min_width); + mPreviewMarginTop = res.getDimensionPixelOffset(R.dimen.fastscroll_preview_margin_top); + mPreviewMarginLeftRight = res.getDimensionPixelOffset( + R.dimen.fastscroll_preview_margin_left_right); + mTouchSlop = res.getDimensionPixelOffset(R.dimen.fastscroll_touch_slop); + + final LayoutInflater inflator = LayoutInflater.from(mContext); + mTrackImageView = (ImageView) inflator.inflate(R.layout.fastscroll_track, null); + mThumbImageView = (ImageView) inflator.inflate(R.layout.fastscroll_thumb, null); + mPreviewTextView = (TextView) inflator.inflate(R.layout.fastscroll_preview, null); + + refreshConversationThemeColor(); + + // Add the fast scroll views to the overlay, so they are rendered above the list + mOverlay = rv.getOverlay(); + mOverlay.add(mTrackImageView); + mOverlay.add(mThumbImageView); + mOverlay.add(mPreviewTextView); + + hide(false /* animate */); + mPreviewTextView.setAlpha(0f); + } + + public void refreshConversationThemeColor() { + mPreviewTextView.setBackground( + ConversationDrawables.get().getFastScrollPreviewDrawable(mPosRight)); + if (OsUtil.isAtLeastL()) { + final StateListDrawable drawable = new StateListDrawable(); + drawable.addState(new int[]{ android.R.attr.state_pressed }, + ConversationDrawables.get().getFastScrollThumbDrawable(true /* pressed */)); + drawable.addState(StateSet.WILD_CARD, + ConversationDrawables.get().getFastScrollThumbDrawable(false /* pressed */)); + mThumbImageView.setImageDrawable(drawable); + } else { + // Android pre-L doesn't seem to handle a StateListDrawable containing a tinted + // drawable (it's rendered in the filter base color, which is red), so fall back to + // just the regular (non-pressed) drawable. + mThumbImageView.setImageDrawable( + ConversationDrawables.get().getFastScrollThumbDrawable(false /* pressed */)); + } + } + + @Override + public void onScrollStateChanged(final RecyclerView view, final int newState) { + if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { + // Only show the scrollbar once the user starts scrolling + if (!mVisible && isEnabled()) { + show(); + } + cancelAnyPendingHide(); + } else if (newState == RecyclerView.SCROLL_STATE_IDLE && !mDragging) { + // Hide the scrollbar again after scrolling stops + hideAfterDelay(); + } + } + + private boolean isEnabled() { + final int range = mRv.computeVerticalScrollRange(); + final int extent = mRv.computeVerticalScrollExtent(); + + if (range == 0 || extent == 0) { + return false; // Conversation isn't long enough to scroll + } + // Only enable scrollbars for conversations long enough that they would require several + // flings to scroll through. + final float pages = (float) range / extent; + return (pages > MIN_PAGES_TO_ENABLE); + } + + private void show() { + if (mHideAnimation != null && mHideAnimation.isRunning()) { + mHideAnimation.cancel(); + } + // Slide the scrollbar in from the side + ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X, 0); + ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X, 0); + AnimatorSet animation = new AnimatorSet(); + animation.playTogether(trackSlide, thumbSlide); + animation.setDuration(SHOW_ANIMATION_DURATION_MS); + animation.start(); + + mVisible = true; + updateScrollPos(); + } + + private void hideAfterDelay() { + cancelAnyPendingHide(); + mHandler.postDelayed(mHideTrackRunnable, HIDE_DELAY_MS); + mPendingHide = true; + } + + private void cancelAnyPendingHide() { + if (mPendingHide) { + mHandler.removeCallbacks(mHideTrackRunnable); + } + } + + private void hide(boolean animate) { + final int hiddenTranslationX = mPosRight ? mTrackWidth : -mTrackWidth; + if (animate) { + // Slide the scrollbar off to the side + ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X, + hiddenTranslationX); + ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X, + hiddenTranslationX); + mHideAnimation = new AnimatorSet(); + mHideAnimation.playTogether(trackSlide, thumbSlide); + mHideAnimation.setDuration(HIDE_ANIMATION_DURATION_MS); + mHideAnimation.start(); + } else { + mTrackImageView.setTranslationX(hiddenTranslationX); + mThumbImageView.setTranslationX(hiddenTranslationX); + } + + mVisible = false; + } + + private void showPreview() { + if (mHidePreviewAnimation != null && mHidePreviewAnimation.isRunning()) { + mHidePreviewAnimation.cancel(); + } + mPreviewTextView.setAlpha(1f); + } + + private void hidePreview() { + mHidePreviewAnimation = ObjectAnimator.ofFloat(mPreviewTextView, View.ALPHA, 0f); + mHidePreviewAnimation.setDuration(HIDE_ANIMATION_DURATION_MS); + mHidePreviewAnimation.start(); + } + + @Override + public void onScrolled(final RecyclerView view, final int dx, final int dy) { + updateScrollPos(); + } + + private void updateScrollPos() { + if (!mVisible) { + return; + } + final int verticalScrollLength = mContainer.height() - mThumbHeight; + final int verticalScrollStart = mContainer.top + mThumbHeight / 2; + + final float scrollRatio = computeScrollRatio(); + final int thumbCenterY = verticalScrollStart + (int)(verticalScrollLength * scrollRatio); + layoutThumb(thumbCenterY); + + if (mDragging) { + updatePreviewText(); + layoutPreview(thumbCenterY); + } + } + + /** + * Returns the current position in the conversation, as a value between 0 and 1, inclusive. + * The top of the conversation is 0, the bottom is 1, the exact middle is 0.5, and so on. + */ + private float computeScrollRatio() { + final int range = mRv.computeVerticalScrollRange(); + final int extent = mRv.computeVerticalScrollExtent(); + int offset = mRv.computeVerticalScrollOffset(); + + if (range == 0 || extent == 0) { + // If the conversation doesn't scroll, we're at the bottom. + return 1.0f; + } + final int scrollRange = range - extent; + offset = Math.min(offset, scrollRange); + return offset / (float) scrollRange; + } + + private void updatePreviewText() { + final LinearLayoutManager lm = (LinearLayoutManager) mRv.getLayoutManager(); + final int pos = lm.findFirstVisibleItemPosition(); + if (pos == RecyclerView.NO_POSITION) { + return; + } + final ViewHolder vh = mRv.findViewHolderForAdapterPosition(pos); + if (vh == null) { + // This can happen if the messages update while we're dragging the thumb. + return; + } + final ConversationMessageView messageView = (ConversationMessageView) vh.itemView; + final ConversationMessageData messageData = messageView.getData(); + final long timestamp = messageData.getReceivedTimeStamp(); + final CharSequence timestampText = Dates.getFastScrollPreviewTimeString(timestamp); + mPreviewTextView.setText(timestampText); + } + + @Override + public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) { + if (!mVisible) { + return false; + } + // If the user presses down on the scroll thumb, we'll start intercepting events from the + // RecyclerView so we can handle the move events while they're dragging it up/down. + final int action = e.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + if (isInsideThumb(e.getX(), e.getY())) { + startDrag(); + return true; + } + break; + case MotionEvent.ACTION_MOVE: + if (mDragging) { + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + if (mDragging) { + cancelDrag(); + } + return false; + } + return false; + } + + private boolean isInsideThumb(float x, float y) { + final int hitTargetLeft = mThumbImageView.getLeft() - mTouchSlop; + final int hitTargetRight = mThumbImageView.getRight() + mTouchSlop; + + if (x < hitTargetLeft || x > hitTargetRight) { + return false; + } + if (y < mThumbImageView.getTop() || y > mThumbImageView.getBottom()) { + return false; + } + return true; + } + + @Override + public void onTouchEvent(RecyclerView rv, MotionEvent e) { + if (!mDragging) { + return; + } + final int action = e.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_MOVE: + handleDragMove(e.getY()); + break; + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + cancelDrag(); + break; + } + } + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { + } + + private void startDrag() { + mDragging = true; + mThumbImageView.setPressed(true); + updateScrollPos(); + showPreview(); + cancelAnyPendingHide(); + } + + private void handleDragMove(float y) { + final int verticalScrollLength = mContainer.height() - mThumbHeight; + final int verticalScrollStart = mContainer.top + (mThumbHeight / 2); + + // Convert the desired position from px to a scroll position in the conversation. + float dragScrollRatio = (y - verticalScrollStart) / verticalScrollLength; + dragScrollRatio = Math.max(dragScrollRatio, 0.0f); + dragScrollRatio = Math.min(dragScrollRatio, 1.0f); + + // Scroll the RecyclerView to a new position. + final int itemCount = mRv.getAdapter().getItemCount(); + final int itemPos = (int)((itemCount - 1) * dragScrollRatio); + mRv.scrollToPosition(itemPos); + } + + private void cancelDrag() { + mDragging = false; + mThumbImageView.setPressed(false); + hidePreview(); + hideAfterDelay(); + } + + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + if (!mVisible) { + hide(false /* animate */); + } + // The container is the size of the RecyclerView that's visible on screen. We have to + // exclude the top padding, because it's usually hidden behind the conversation action bar. + mContainer.set(left, top + mRv.getPaddingTop(), right, bottom); + layoutTrack(); + updateScrollPos(); + } + + private void layoutTrack() { + int trackHeight = Math.max(0, mContainer.height()); + int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY); + int heightMeasureSpec = MeasureSpec.makeMeasureSpec(trackHeight, MeasureSpec.EXACTLY); + mTrackImageView.measure(widthMeasureSpec, heightMeasureSpec); + + int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left; + int top = mContainer.top; + int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth); + int bottom = mContainer.bottom; + mTrackImageView.layout(left, top, right, bottom); + } + + private void layoutThumb(int centerY) { + int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY); + int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mThumbHeight, MeasureSpec.EXACTLY); + mThumbImageView.measure(widthMeasureSpec, heightMeasureSpec); + + int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left; + int top = centerY - (mThumbImageView.getHeight() / 2); + int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth); + int bottom = top + mThumbHeight; + mThumbImageView.layout(left, top, right, bottom); + } + + private void layoutPreview(int centerY) { + int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mContainer.width(), MeasureSpec.AT_MOST); + int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewHeight, MeasureSpec.EXACTLY); + mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec); + + // Ensure that the preview bubble is at least as wide as it is tall + if (mPreviewTextView.getMeasuredWidth() < mPreviewMinWidth) { + widthMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewMinWidth, MeasureSpec.EXACTLY); + mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec); + } + final int previewMinY = mContainer.top + mPreviewMarginTop; + + final int left, right; + if (mPosRight) { + right = mContainer.right - mTrackWidth - mPreviewMarginLeftRight; + left = right - mPreviewTextView.getMeasuredWidth(); + } else { + left = mContainer.left + mTrackWidth + mPreviewMarginLeftRight; + right = left + mPreviewTextView.getMeasuredWidth(); + } + + int bottom = centerY; + int top = bottom - mPreviewTextView.getMeasuredHeight(); + if (top < previewMinY) { + top = previewMinY; + bottom = top + mPreviewTextView.getMeasuredHeight(); + } + mPreviewTextView.layout(left, top, right, bottom); + } +} |