Initial checkin of AOSP Messaging app.

b/23110861

Change-Id: I9aa980d7569247d6b2ca78f5dcb4502e1eaadb8a
This commit is contained in:
Mike Dodd
2015-08-12 08:58:28 -07:00
parent 8b3e2b9c1b
commit 461a34b466
1645 changed files with 186271 additions and 0 deletions
@@ -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);
}
}