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