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
17package com.android.messaging.ui.conversation;
18
19import android.animation.AnimatorSet;
20import android.animation.ObjectAnimator;
21import android.content.Context;
22import android.content.res.Resources;
23import android.graphics.Rect;
24import android.graphics.drawable.StateListDrawable;
25import android.os.Handler;
26import android.support.v7.widget.LinearLayoutManager;
27import android.support.v7.widget.RecyclerView;
28import android.support.v7.widget.RecyclerView.AdapterDataObserver;
29import android.support.v7.widget.RecyclerView.ViewHolder;
30import android.util.StateSet;
31import android.view.LayoutInflater;
32import android.view.MotionEvent;
33import android.view.View;
34import android.view.View.MeasureSpec;
35import android.view.View.OnLayoutChangeListener;
36import android.view.ViewGroupOverlay;
37import android.widget.ImageView;
38import android.widget.TextView;
39
40import com.android.messaging.R;
41import com.android.messaging.datamodel.data.ConversationMessageData;
42import com.android.messaging.ui.ConversationDrawables;
43import com.android.messaging.util.Dates;
44import 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 */
52public 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