Home | History | Annotate | Download | only in conversation
      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