/*
* Copyright (C) 2008 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 android.widget;
import android.animation.Animator;
import android.animation.Animator.AnimatorListener;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.PropertyValuesHolder;
import android.content.Context;
import android.content.res.ColorStateList;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.TextUtils.TruncateAt;
import android.util.IntProperty;
import android.util.MathUtils;
import android.util.Property;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.MeasureSpec;
import android.view.ViewConfiguration;
import android.view.ViewGroup.LayoutParams;
import android.view.ViewGroupOverlay;
import android.widget.AbsListView.OnScrollListener;
import com.android.internal.R;
/**
* Helper class for AbsListView to draw and control the Fast Scroll thumb
*/
class FastScroller {
/** Duration of fade-out animation. */
private static final int DURATION_FADE_OUT = 300;
/** Duration of fade-in animation. */
private static final int DURATION_FADE_IN = 150;
/** Duration of transition cross-fade animation. */
private static final int DURATION_CROSS_FADE = 50;
/** Duration of transition resize animation. */
private static final int DURATION_RESIZE = 100;
/** Inactivity timeout before fading controls. */
private static final long FADE_TIMEOUT = 1500;
/** Minimum number of pages to justify showing a fast scroll thumb. */
private static final int MIN_PAGES = 4;
/** Scroll thumb and preview not showing. */
private static final int STATE_NONE = 0;
/** Scroll thumb visible and moving along with the scrollbar. */
private static final int STATE_VISIBLE = 1;
/** Scroll thumb and preview being dragged by user. */
private static final int STATE_DRAGGING = 2;
/** Styleable attributes. */
private static final int[] ATTRS = new int[] {
android.R.attr.fastScrollTextColor,
android.R.attr.fastScrollThumbDrawable,
android.R.attr.fastScrollTrackDrawable,
android.R.attr.fastScrollPreviewBackgroundLeft,
android.R.attr.fastScrollPreviewBackgroundRight,
android.R.attr.fastScrollOverlayPosition
};
// Styleable attribute indices.
private static final int TEXT_COLOR = 0;
private static final int THUMB_DRAWABLE = 1;
private static final int TRACK_DRAWABLE = 2;
private static final int PREVIEW_BACKGROUND_LEFT = 3;
private static final int PREVIEW_BACKGROUND_RIGHT = 4;
private static final int OVERLAY_POSITION = 5;
// Positions for preview image and text.
private static final int OVERLAY_FLOATING = 0;
private static final int OVERLAY_AT_THUMB = 1;
// Indices for mPreviewResId.
private static final int PREVIEW_LEFT = 0;
private static final int PREVIEW_RIGHT = 1;
/** Delay before considering a tap in the thumb area to be a drag. */
private static final long TAP_TIMEOUT = ViewConfiguration.getTapTimeout();
private final Rect mTempBounds = new Rect();
private final Rect mTempMargins = new Rect();
private final Rect mContainerRect = new Rect();
private final AbsListView mList;
private final ViewGroupOverlay mOverlay;
private final TextView mPrimaryText;
private final TextView mSecondaryText;
private final ImageView mThumbImage;
private final ImageView mTrackImage;
private final ImageView mPreviewImage;
/**
* Preview image resource IDs for left- and right-aligned layouts. See
* {@link #PREVIEW_LEFT} and {@link #PREVIEW_RIGHT}.
*/
private final int[] mPreviewResId = new int[2];
/**
* Padding in pixels around the preview text. Applied as layout margins to
* the preview text and padding to the preview image.
*/
private final int mPreviewPadding;
/** Whether there is a track image to display. */
private final boolean mHasTrackImage;
/** Total width of decorations. */
private final int mWidth;
/** Set containing decoration transition animations. */
private AnimatorSet mDecorAnimation;
/** Set containing preview text transition animations. */
private AnimatorSet mPreviewAnimation;
/** Whether the primary text is showing. */
private boolean mShowingPrimary;
/** Whether we're waiting for completion of scrollTo(). */
private boolean mScrollCompleted;
/** The position of the first visible item in the list. */
private int mFirstVisibleItem;
/** The number of headers at the top of the view. */
private int mHeaderCount;
/** The index of the current section. */
private int mCurrentSection = -1;
/** The current scrollbar position. */
private int mScrollbarPosition = -1;
/** Whether the list is long enough to need a fast scroller. */
private boolean mLongList;
private Object[] mSections;
/** Whether this view is currently performing layout. */
private boolean mUpdatingLayout;
/**
* Current decoration state, one of:
*
* - {@link #STATE_NONE}, nothing visible
*
- {@link #STATE_VISIBLE}, showing track and thumb
*
- {@link #STATE_DRAGGING}, visible and showing preview
*
*/
private int mState;
private BaseAdapter mListAdapter;
private SectionIndexer mSectionIndexer;
/** Whether decorations should be laid out from right to left. */
private boolean mLayoutFromRight;
/** Whether the fast scroller is enabled. */
private boolean mEnabled;
/** Whether the scrollbar and decorations should always be shown. */
private boolean mAlwaysShow;
/**
* Position for the preview image and text. One of:
*
* - {@link #OVERLAY_AT_THUMB}
*
- {@link #OVERLAY_FLOATING}
*
*/
private int mOverlayPosition;
/** Current scrollbar style, including inset and overlay properties. */
private int mScrollBarStyle;
/** Whether to precisely match the thumb position to the list. */
private boolean mMatchDragPosition;
private float mInitialTouchY;
private boolean mHasPendingDrag;
private int mScaledTouchSlop;
private final Runnable mDeferStartDrag = new Runnable() {
@Override
public void run() {
if (mList.isAttachedToWindow()) {
beginDrag();
final float pos = getPosFromMotionEvent(mInitialTouchY);
scrollTo(pos);
}
mHasPendingDrag = false;
}
};
/**
* Used to delay hiding fast scroll decorations.
*/
private final Runnable mDeferHide = new Runnable() {
@Override
public void run() {
setState(STATE_NONE);
}
};
/**
* Used to effect a transition from primary to secondary text.
*/
private final AnimatorListener mSwitchPrimaryListener = new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
mShowingPrimary = !mShowingPrimary;
}
};
public FastScroller(AbsListView listView) {
mList = listView;
mOverlay = listView.getOverlay();
final Context context = listView.getContext();
mScaledTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
final Resources res = context.getResources();
final TypedArray ta = context.getTheme().obtainStyledAttributes(ATTRS);
final ImageView trackImage = new ImageView(context);
mTrackImage = trackImage;
int width = 0;
// Add track to overlay if it has an image.
final Drawable trackDrawable = ta.getDrawable(TRACK_DRAWABLE);
if (trackDrawable != null) {
mHasTrackImage = true;
trackImage.setBackground(trackDrawable);
mOverlay.add(trackImage);
width = Math.max(width, trackDrawable.getIntrinsicWidth());
} else {
mHasTrackImage = false;
}
final ImageView thumbImage = new ImageView(context);
mThumbImage = thumbImage;
// Add thumb to overlay if it has an image.
final Drawable thumbDrawable = ta.getDrawable(THUMB_DRAWABLE);
if (thumbDrawable != null) {
thumbImage.setImageDrawable(thumbDrawable);
mOverlay.add(thumbImage);
width = Math.max(width, thumbDrawable.getIntrinsicWidth());
}
// If necessary, apply minimum thumb width and height.
if (thumbDrawable.getIntrinsicWidth() <= 0 || thumbDrawable.getIntrinsicHeight() <= 0) {
final int minWidth = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_width);
thumbImage.setMinimumWidth(minWidth);
thumbImage.setMinimumHeight(
res.getDimensionPixelSize(R.dimen.fastscroll_thumb_height));
width = Math.max(width, minWidth);
}
mWidth = width;
final int previewSize = res.getDimensionPixelSize(R.dimen.fastscroll_overlay_size);
mPreviewImage = new ImageView(context);
mPreviewImage.setMinimumWidth(previewSize);
mPreviewImage.setMinimumHeight(previewSize);
mPreviewImage.setAlpha(0f);
mOverlay.add(mPreviewImage);
mPreviewPadding = res.getDimensionPixelSize(R.dimen.fastscroll_overlay_padding);
final int textMinSize = Math.max(0, previewSize - mPreviewPadding);
mPrimaryText = createPreviewTextView(context, ta);
mPrimaryText.setMinimumWidth(textMinSize);
mPrimaryText.setMinimumHeight(textMinSize);
mOverlay.add(mPrimaryText);
mSecondaryText = createPreviewTextView(context, ta);
mSecondaryText.setMinimumWidth(textMinSize);
mSecondaryText.setMinimumHeight(textMinSize);
mOverlay.add(mSecondaryText);
mPreviewResId[PREVIEW_LEFT] = ta.getResourceId(PREVIEW_BACKGROUND_LEFT, 0);
mPreviewResId[PREVIEW_RIGHT] = ta.getResourceId(PREVIEW_BACKGROUND_RIGHT, 0);
mOverlayPosition = ta.getInt(OVERLAY_POSITION, OVERLAY_FLOATING);
ta.recycle();
mScrollBarStyle = listView.getScrollBarStyle();
mScrollCompleted = true;
mState = STATE_VISIBLE;
mMatchDragPosition = context.getApplicationInfo().targetSdkVersion
>= Build.VERSION_CODES.HONEYCOMB;
getSectionsFromIndexer();
refreshDrawablePressedState();
updateLongList(listView.getChildCount(), listView.getCount());
setScrollbarPosition(mList.getVerticalScrollbarPosition());
postAutoHide();
}
/**
* Removes this FastScroller overlay from the host view.
*/
public void remove() {
mOverlay.remove(mTrackImage);
mOverlay.remove(mThumbImage);
mOverlay.remove(mPreviewImage);
mOverlay.remove(mPrimaryText);
mOverlay.remove(mSecondaryText);
}
/**
* @param enabled Whether the fast scroll thumb is enabled.
*/
public void setEnabled(boolean enabled) {
if (mEnabled != enabled) {
mEnabled = enabled;
onStateDependencyChanged();
}
}
/**
* @return Whether the fast scroll thumb is enabled.
*/
public boolean isEnabled() {
return mEnabled && (mLongList || mAlwaysShow);
}
/**
* @param alwaysShow Whether the fast scroll thumb should always be shown
*/
public void setAlwaysShow(boolean alwaysShow) {
if (mAlwaysShow != alwaysShow) {
mAlwaysShow = alwaysShow;
onStateDependencyChanged();
}
}
/**
* @return Whether the fast scroll thumb will always be shown
* @see #setAlwaysShow(boolean)
*/
public boolean isAlwaysShowEnabled() {
return mAlwaysShow;
}
/**
* Called when one of the variables affecting enabled state changes.
*/
private void onStateDependencyChanged() {
if (isEnabled()) {
if (isAlwaysShowEnabled()) {
setState(STATE_VISIBLE);
} else if (mState == STATE_VISIBLE) {
postAutoHide();
}
} else {
stop();
}
mList.resolvePadding();
}
public void setScrollBarStyle(int style) {
if (mScrollBarStyle != style) {
mScrollBarStyle = style;
updateLayout();
}
}
/**
* Immediately transitions the fast scroller decorations to a hidden state.
*/
public void stop() {
setState(STATE_NONE);
}
public void setScrollbarPosition(int position) {
if (position == View.SCROLLBAR_POSITION_DEFAULT) {
position = mList.isLayoutRtl() ?
View.SCROLLBAR_POSITION_LEFT : View.SCROLLBAR_POSITION_RIGHT;
}
if (mScrollbarPosition != position) {
mScrollbarPosition = position;
mLayoutFromRight = position != View.SCROLLBAR_POSITION_LEFT;
final int previewResId = mPreviewResId[mLayoutFromRight ? PREVIEW_RIGHT : PREVIEW_LEFT];
mPreviewImage.setBackgroundResource(previewResId);
// Add extra padding for text.
final Drawable background = mPreviewImage.getBackground();
if (background != null) {
final Rect padding = mTempBounds;
background.getPadding(padding);
padding.offset(mPreviewPadding, mPreviewPadding);
mPreviewImage.setPadding(padding.left, padding.top, padding.right, padding.bottom);
}
// Requires re-layout.
updateLayout();
}
}
public int getWidth() {
return mWidth;
}
public void onSizeChanged(int w, int h, int oldw, int oldh) {
updateLayout();
}
public void onItemCountChanged(int oldTotalItemCount, int totalItemCount) {
final int visibleItemCount = mList.getChildCount();
final boolean hasMoreItems = totalItemCount - visibleItemCount > 0;
if (hasMoreItems && mState != STATE_DRAGGING) {
final int firstVisibleItem = mList.getFirstVisiblePosition();
setThumbPos(getPosFromItemCount(firstVisibleItem, visibleItemCount, totalItemCount));
}
updateLongList(visibleItemCount, totalItemCount);
}
private void updateLongList(int visibleItemCount, int totalItemCount) {
final boolean longList = visibleItemCount > 0
&& totalItemCount / visibleItemCount >= MIN_PAGES;
if (mLongList != longList) {
mLongList = longList;
onStateDependencyChanged();
}
}
/**
* Creates a view into which preview text can be placed.
*/
private TextView createPreviewTextView(Context context, TypedArray ta) {
final LayoutParams params = new LayoutParams(
LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
final Resources res = context.getResources();
final int minSize = res.getDimensionPixelSize(R.dimen.fastscroll_overlay_size);
final ColorStateList textColor = ta.getColorStateList(TEXT_COLOR);
final float textSize = res.getDimensionPixelSize(R.dimen.fastscroll_overlay_text_size);
final TextView textView = new TextView(context);
textView.setLayoutParams(params);
textView.setTextColor(textColor);
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize);
textView.setSingleLine(true);
textView.setEllipsize(TruncateAt.MIDDLE);
textView.setGravity(Gravity.CENTER);
textView.setAlpha(0f);
// Manually propagate inherited layout direction.
textView.setLayoutDirection(mList.getLayoutDirection());
return textView;
}
/**
* Measures and layouts the scrollbar and decorations.
*/
public void updateLayout() {
// Prevent re-entry when RTL properties change as a side-effect of
// resolving padding.
if (mUpdatingLayout) {
return;
}
mUpdatingLayout = true;
updateContainerRect();
layoutThumb();
layoutTrack();
final Rect bounds = mTempBounds;
measurePreview(mPrimaryText, bounds);
applyLayout(mPrimaryText, bounds);
measurePreview(mSecondaryText, bounds);
applyLayout(mSecondaryText, bounds);
if (mPreviewImage != null) {
// Apply preview image padding.
bounds.left -= mPreviewImage.getPaddingLeft();
bounds.top -= mPreviewImage.getPaddingTop();
bounds.right += mPreviewImage.getPaddingRight();
bounds.bottom += mPreviewImage.getPaddingBottom();
applyLayout(mPreviewImage, bounds);
}
mUpdatingLayout = false;
}
/**
* Layouts a view within the specified bounds and pins the pivot point to
* the appropriate edge.
*
* @param view The view to layout.
* @param bounds Bounds at which to layout the view.
*/
private void applyLayout(View view, Rect bounds) {
view.layout(bounds.left, bounds.top, bounds.right, bounds.bottom);
view.setPivotX(mLayoutFromRight ? bounds.right - bounds.left : 0);
}
/**
* Measures the preview text bounds, taking preview image padding into
* account. This method should only be called after {@link #layoutThumb()}
* and {@link #layoutTrack()} have both been called at least once.
*
* @param v The preview text view to measure.
* @param out Rectangle into which measured bounds are placed.
*/
private void measurePreview(View v, Rect out) {
// Apply the preview image's padding as layout margins.
final Rect margins = mTempMargins;
margins.left = mPreviewImage.getPaddingLeft();
margins.top = mPreviewImage.getPaddingTop();
margins.right = mPreviewImage.getPaddingRight();
margins.bottom = mPreviewImage.getPaddingBottom();
if (mOverlayPosition == OVERLAY_AT_THUMB) {
measureViewToSide(v, mThumbImage, margins, out);
} else {
measureFloating(v, margins, out);
}
}
/**
* Measures the bounds for a view that should be laid out against the edge
* of an adjacent view. If no adjacent view is provided, lays out against
* the list edge.
*
* @param view The view to measure for layout.
* @param adjacent (Optional) The adjacent view, may be null to align to the
* list edge.
* @param margins Layout margins to apply to the view.
* @param out Rectangle into which measured bounds are placed.
*/
private void measureViewToSide(View view, View adjacent, Rect margins, Rect out) {
final int marginLeft;
final int marginTop;
final int marginRight;
if (margins == null) {
marginLeft = 0;
marginTop = 0;
marginRight = 0;
} else {
marginLeft = margins.left;
marginTop = margins.top;
marginRight = margins.right;
}
final Rect container = mContainerRect;
final int containerWidth = container.width();
final int maxWidth;
if (adjacent == null) {
maxWidth = containerWidth;
} else if (mLayoutFromRight) {
maxWidth = adjacent.getLeft();
} else {
maxWidth = containerWidth - adjacent.getRight();
}
final int adjMaxWidth = maxWidth - marginLeft - marginRight;
final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(adjMaxWidth, MeasureSpec.AT_MOST);
final int heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
view.measure(widthMeasureSpec, heightMeasureSpec);
// Align to the left or right.
final int width = view.getMeasuredWidth();
final int left;
final int right;
if (mLayoutFromRight) {
right = (adjacent == null ? container.right : adjacent.getLeft()) - marginRight;
left = right - width;
} else {
left = (adjacent == null ? container.left : adjacent.getRight()) + marginLeft;
right = left + width;
}
// Don't adjust the vertical position.
final int top = marginTop;
final int bottom = top + view.getMeasuredHeight();
out.set(left, top, right, bottom);
}
private void measureFloating(View preview, Rect margins, Rect out) {
final int marginLeft;
final int marginTop;
final int marginRight;
if (margins == null) {
marginLeft = 0;
marginTop = 0;
marginRight = 0;
} else {
marginLeft = margins.left;
marginTop = margins.top;
marginRight = margins.right;
}
final Rect container = mContainerRect;
final int containerWidth = container.width();
final int adjMaxWidth = containerWidth - marginLeft - marginRight;
final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(adjMaxWidth, MeasureSpec.AT_MOST);
final int heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
preview.measure(widthMeasureSpec, heightMeasureSpec);
// Align at the vertical center, 10% from the top.
final int containerHeight = container.height();
final int width = preview.getMeasuredWidth();
final int top = containerHeight / 10 + marginTop + container.top;
final int bottom = top + preview.getMeasuredHeight();
final int left = (containerWidth - width) / 2 + container.left;
final int right = left + width;
out.set(left, top, right, bottom);
}
/**
* Updates the container rectangle used for layout.
*/
private void updateContainerRect() {
final AbsListView list = mList;
list.resolvePadding();
final Rect container = mContainerRect;
container.left = 0;
container.top = 0;
container.right = list.getWidth();
container.bottom = list.getHeight();
final int scrollbarStyle = mScrollBarStyle;
if (scrollbarStyle == View.SCROLLBARS_INSIDE_INSET
|| scrollbarStyle == View.SCROLLBARS_INSIDE_OVERLAY) {
container.left += list.getPaddingLeft();
container.top += list.getPaddingTop();
container.right -= list.getPaddingRight();
container.bottom -= list.getPaddingBottom();
// In inset mode, we need to adjust for padded scrollbar width.
if (scrollbarStyle == View.SCROLLBARS_INSIDE_INSET) {
final int width = getWidth();
if (mScrollbarPosition == View.SCROLLBAR_POSITION_RIGHT) {
container.right += width;
} else {
container.left -= width;
}
}
}
}
/**
* Lays out the thumb according to the current scrollbar position.
*/
private void layoutThumb() {
final Rect bounds = mTempBounds;
measureViewToSide(mThumbImage, null, null, bounds);
applyLayout(mThumbImage, bounds);
}
/**
* Lays out the track centered on the thumb. Must be called after
* {@link #layoutThumb}.
*/
private void layoutTrack() {
final View track = mTrackImage;
final View thumb = mThumbImage;
final Rect container = mContainerRect;
final int containerWidth = container.width();
final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(containerWidth, MeasureSpec.AT_MOST);
final int heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
track.measure(widthMeasureSpec, heightMeasureSpec);
final int trackWidth = track.getMeasuredWidth();
final int thumbHalfHeight = thumb == null ? 0 : thumb.getHeight() / 2;
final int left = thumb.getLeft() + (thumb.getWidth() - trackWidth) / 2;
final int right = left + trackWidth;
final int top = container.top + thumbHalfHeight;
final int bottom = container.bottom - thumbHalfHeight;
track.layout(left, top, right, bottom);
}
private void setState(int state) {
mList.removeCallbacks(mDeferHide);
if (mAlwaysShow && state == STATE_NONE) {
state = STATE_VISIBLE;
}
if (state == mState) {
return;
}
switch (state) {
case STATE_NONE:
transitionToHidden();
break;
case STATE_VISIBLE:
transitionToVisible();
break;
case STATE_DRAGGING:
if (transitionPreviewLayout(mCurrentSection)) {
transitionToDragging();
} else {
transitionToVisible();
}
break;
}
mState = state;
refreshDrawablePressedState();
}
private void refreshDrawablePressedState() {
final boolean isPressed = mState == STATE_DRAGGING;
mThumbImage.setPressed(isPressed);
mTrackImage.setPressed(isPressed);
}
/**
* Shows nothing.
*/
private void transitionToHidden() {
if (mDecorAnimation != null) {
mDecorAnimation.cancel();
}
final Animator fadeOut = groupAnimatorOfFloat(View.ALPHA, 0f, mThumbImage, mTrackImage,
mPreviewImage, mPrimaryText, mSecondaryText).setDuration(DURATION_FADE_OUT);
// Push the thumb and track outside the list bounds.
final float offset = mLayoutFromRight ? mThumbImage.getWidth() : -mThumbImage.getWidth();
final Animator slideOut = groupAnimatorOfFloat(
View.TRANSLATION_X, offset, mThumbImage, mTrackImage)
.setDuration(DURATION_FADE_OUT);
mDecorAnimation = new AnimatorSet();
mDecorAnimation.playTogether(fadeOut, slideOut);
mDecorAnimation.start();
}
/**
* Shows the thumb and track.
*/
private void transitionToVisible() {
if (mDecorAnimation != null) {
mDecorAnimation.cancel();
}
final Animator fadeIn = groupAnimatorOfFloat(View.ALPHA, 1f, mThumbImage, mTrackImage)
.setDuration(DURATION_FADE_IN);
final Animator fadeOut = groupAnimatorOfFloat(
View.ALPHA, 0f, mPreviewImage, mPrimaryText, mSecondaryText)
.setDuration(DURATION_FADE_OUT);
final Animator slideIn = groupAnimatorOfFloat(
View.TRANSLATION_X, 0f, mThumbImage, mTrackImage).setDuration(DURATION_FADE_IN);
mDecorAnimation = new AnimatorSet();
mDecorAnimation.playTogether(fadeIn, fadeOut, slideIn);
mDecorAnimation.start();
}
/**
* Shows the thumb, preview, and track.
*/
private void transitionToDragging() {
if (mDecorAnimation != null) {
mDecorAnimation.cancel();
}
final Animator fadeIn = groupAnimatorOfFloat(
View.ALPHA, 1f, mThumbImage, mTrackImage, mPreviewImage)
.setDuration(DURATION_FADE_IN);
final Animator slideIn = groupAnimatorOfFloat(
View.TRANSLATION_X, 0f, mThumbImage, mTrackImage).setDuration(DURATION_FADE_IN);
mDecorAnimation = new AnimatorSet();
mDecorAnimation.playTogether(fadeIn, slideIn);
mDecorAnimation.start();
}
private void postAutoHide() {
mList.removeCallbacks(mDeferHide);
mList.postDelayed(mDeferHide, FADE_TIMEOUT);
}
public void onScroll(int firstVisibleItem, int visibleItemCount, int totalItemCount) {
if (!isEnabled()) {
setState(STATE_NONE);
return;
}
final boolean hasMoreItems = totalItemCount - visibleItemCount > 0;
if (hasMoreItems && mState != STATE_DRAGGING) {
setThumbPos(getPosFromItemCount(firstVisibleItem, visibleItemCount, totalItemCount));
}
mScrollCompleted = true;
if (mFirstVisibleItem != firstVisibleItem) {
mFirstVisibleItem = firstVisibleItem;
// Show the thumb, if necessary, and set up auto-fade.
if (mState != STATE_DRAGGING) {
setState(STATE_VISIBLE);
postAutoHide();
}
}
}
private void getSectionsFromIndexer() {
mSectionIndexer = null;
Adapter adapter = mList.getAdapter();
if (adapter instanceof HeaderViewListAdapter) {
mHeaderCount = ((HeaderViewListAdapter) adapter).getHeadersCount();
adapter = ((HeaderViewListAdapter) adapter).getWrappedAdapter();
}
if (adapter instanceof ExpandableListConnector) {
final ExpandableListAdapter expAdapter = ((ExpandableListConnector) adapter)
.getAdapter();
if (expAdapter instanceof SectionIndexer) {
mSectionIndexer = (SectionIndexer) expAdapter;
mListAdapter = (BaseAdapter) adapter;
mSections = mSectionIndexer.getSections();
}
} else if (adapter instanceof SectionIndexer) {
mListAdapter = (BaseAdapter) adapter;
mSectionIndexer = (SectionIndexer) adapter;
mSections = mSectionIndexer.getSections();
} else {
mListAdapter = (BaseAdapter) adapter;
mSections = null;
}
}
public void onSectionsChanged() {
mListAdapter = null;
}
/**
* Scrolls to a specific position within the section
* @param position
*/
private void scrollTo(float position) {
mScrollCompleted = false;
final int count = mList.getCount();
final Object[] sections = mSections;
final int sectionCount = sections == null ? 0 : sections.length;
int sectionIndex;
if (sections != null && sectionCount > 1) {
final int exactSection = MathUtils.constrain(
(int) (position * sectionCount), 0, sectionCount - 1);
int targetSection = exactSection;
int targetIndex = mSectionIndexer.getPositionForSection(targetSection);
sectionIndex = targetSection;
// Given the expected section and index, the following code will
// try to account for missing sections (no names starting with..)
// It will compute the scroll space of surrounding empty sections
// and interpolate the currently visible letter's range across the
// available space, so that there is always some list movement while
// the user moves the thumb.
int nextIndex = count;
int prevIndex = targetIndex;
int prevSection = targetSection;
int nextSection = targetSection + 1;
// Assume the next section is unique
if (targetSection < sectionCount - 1) {
nextIndex = mSectionIndexer.getPositionForSection(targetSection + 1);
}
// Find the previous index if we're slicing the previous section
if (nextIndex == targetIndex) {
// Non-existent letter
while (targetSection > 0) {
targetSection--;
prevIndex = mSectionIndexer.getPositionForSection(targetSection);
if (prevIndex != targetIndex) {
prevSection = targetSection;
sectionIndex = targetSection;
break;
} else if (targetSection == 0) {
// When section reaches 0 here, sectionIndex must follow it.
// Assuming mSectionIndexer.getPositionForSection(0) == 0.
sectionIndex = 0;
break;
}
}
}
// Find the next index, in case the assumed next index is not
// unique. For instance, if there is no P, then request for P's
// position actually returns Q's. So we need to look ahead to make
// sure that there is really a Q at Q's position. If not, move
// further down...
int nextNextSection = nextSection + 1;
while (nextNextSection < sectionCount &&
mSectionIndexer.getPositionForSection(nextNextSection) == nextIndex) {
nextNextSection++;
nextSection++;
}
// Compute the beginning and ending scroll range percentage of the
// currently visible section. This could be equal to or greater than
// (1 / nSections). If the target position is near the previous
// position, snap to the previous position.
final float prevPosition = (float) prevSection / sectionCount;
final float nextPosition = (float) nextSection / sectionCount;
final float snapThreshold = (count == 0) ? Float.MAX_VALUE : .125f / count;
if (prevSection == exactSection && position - prevPosition < snapThreshold) {
targetIndex = prevIndex;
} else {
targetIndex = prevIndex + (int) ((nextIndex - prevIndex) * (position - prevPosition)
/ (nextPosition - prevPosition));
}
// Clamp to valid positions.
targetIndex = MathUtils.constrain(targetIndex, 0, count - 1);
if (mList instanceof ExpandableListView) {
final ExpandableListView expList = (ExpandableListView) mList;
expList.setSelectionFromTop(expList.getFlatListPosition(
ExpandableListView.getPackedPositionForGroup(targetIndex + mHeaderCount)),
0);
} else if (mList instanceof ListView) {
((ListView) mList).setSelectionFromTop(targetIndex + mHeaderCount, 0);
} else {
mList.setSelection(targetIndex + mHeaderCount);
}
} else {
final int index = MathUtils.constrain((int) (position * count), 0, count - 1);
if (mList instanceof ExpandableListView) {
ExpandableListView expList = (ExpandableListView) mList;
expList.setSelectionFromTop(expList.getFlatListPosition(
ExpandableListView.getPackedPositionForGroup(index + mHeaderCount)), 0);
} else if (mList instanceof ListView) {
((ListView)mList).setSelectionFromTop(index + mHeaderCount, 0);
} else {
mList.setSelection(index + mHeaderCount);
}
sectionIndex = -1;
}
if (mCurrentSection != sectionIndex) {
mCurrentSection = sectionIndex;
if (transitionPreviewLayout(sectionIndex)) {
transitionToDragging();
} else {
transitionToVisible();
}
}
}
/**
* Transitions the preview text to a new section. Handles animation,
* measurement, and layout. If the new preview text is empty, returns false.
*
* @param sectionIndex The section index to which the preview should
* transition.
* @return False if the new preview text is empty.
*/
private boolean transitionPreviewLayout(int sectionIndex) {
final Object[] sections = mSections;
String text = null;
if (sections != null && sectionIndex >= 0 && sectionIndex < sections.length) {
final Object section = sections[sectionIndex];
if (section != null) {
text = section.toString();
}
}
final Rect bounds = mTempBounds;
final ImageView preview = mPreviewImage;
final TextView showing;
final TextView target;
if (mShowingPrimary) {
showing = mPrimaryText;
target = mSecondaryText;
} else {
showing = mSecondaryText;
target = mPrimaryText;
}
// Set and layout target immediately.
target.setText(text);
measurePreview(target, bounds);
applyLayout(target, bounds);
if (mPreviewAnimation != null) {
mPreviewAnimation.cancel();
}
// Cross-fade preview text.
final Animator showTarget = animateAlpha(target, 1f).setDuration(DURATION_CROSS_FADE);
final Animator hideShowing = animateAlpha(showing, 0f).setDuration(DURATION_CROSS_FADE);
hideShowing.addListener(mSwitchPrimaryListener);
// Apply preview image padding and animate bounds, if necessary.
bounds.left -= mPreviewImage.getPaddingLeft();
bounds.top -= mPreviewImage.getPaddingTop();
bounds.right += mPreviewImage.getPaddingRight();
bounds.bottom += mPreviewImage.getPaddingBottom();
final Animator resizePreview = animateBounds(preview, bounds);
resizePreview.setDuration(DURATION_RESIZE);
mPreviewAnimation = new AnimatorSet();
final AnimatorSet.Builder builder = mPreviewAnimation.play(hideShowing).with(showTarget);
builder.with(resizePreview);
// The current preview size is unaffected by hidden or showing. It's
// used to set starting scales for things that need to be scaled down.
final int previewWidth = preview.getWidth() - preview.getPaddingLeft()
- preview.getPaddingRight();
// If target is too large, shrink it immediately to fit and expand to
// target size. Otherwise, start at target size.
final int targetWidth = target.getWidth();
if (targetWidth > previewWidth) {
target.setScaleX((float) previewWidth / targetWidth);
final Animator scaleAnim = animateScaleX(target, 1f).setDuration(DURATION_RESIZE);
builder.with(scaleAnim);
} else {
target.setScaleX(1f);
}
// If showing is larger than target, shrink to target size.
final int showingWidth = showing.getWidth();
if (showingWidth > targetWidth) {
final float scale = (float) targetWidth / showingWidth;
final Animator scaleAnim = animateScaleX(showing, scale).setDuration(DURATION_RESIZE);
builder.with(scaleAnim);
}
mPreviewAnimation.start();
return (text != null && text.length() > 0);
}
/**
* Positions the thumb and preview widgets.
*
* @param position The position, between 0 and 1, along the track at which
* to place the thumb.
*/
private void setThumbPos(float position) {
final Rect container = mContainerRect;
final int top = container.top;
final int bottom = container.bottom;
final ImageView trackImage = mTrackImage;
final ImageView thumbImage = mThumbImage;
final float min = trackImage.getTop();
final float max = trackImage.getBottom();
final float offset = min;
final float range = max - min;
final float thumbMiddle = position * range + offset;
thumbImage.setTranslationY(thumbMiddle - thumbImage.getHeight() / 2);
// Center the preview on the thumb, constrained to the list bounds.
final ImageView previewImage = mPreviewImage;
final float previewHalfHeight = previewImage.getHeight() / 2f;
final float minP = top + previewHalfHeight;
final float maxP = bottom - previewHalfHeight;
final float previewMiddle = MathUtils.constrain(thumbMiddle, minP, maxP);
final float previewTop = previewMiddle - previewHalfHeight;
previewImage.setTranslationY(previewTop);
mPrimaryText.setTranslationY(previewTop);
mSecondaryText.setTranslationY(previewTop);
}
private float getPosFromMotionEvent(float y) {
final Rect container = mContainerRect;
final int top = container.top;
final int bottom = container.bottom;
final ImageView trackImage = mTrackImage;
final float min = trackImage.getTop();
final float max = trackImage.getBottom();
final float offset = min;
final float range = max - min;
// If the list is the same height as the thumbnail or shorter,
// effectively disable scrolling.
if (range <= 0) {
return 0f;
}
return MathUtils.constrain((y - offset) / range, 0f, 1f);
}
private float getPosFromItemCount(
int firstVisibleItem, int visibleItemCount, int totalItemCount) {
if (mSectionIndexer == null || mListAdapter == null) {
getSectionsFromIndexer();
}
final boolean hasSections = mSectionIndexer != null && mSections != null
&& mSections.length > 0;
if (!hasSections || !mMatchDragPosition) {
return (float) firstVisibleItem / (totalItemCount - visibleItemCount);
}
// Ignore headers.
firstVisibleItem -= mHeaderCount;
if (firstVisibleItem < 0) {
return 0;
}
totalItemCount -= mHeaderCount;
// Hidden portion of the first visible row.
final View child = mList.getChildAt(0);
final float incrementalPos;
if (child == null || child.getHeight() == 0) {
incrementalPos = 0;
} else {
incrementalPos = (float) (mList.getPaddingTop() - child.getTop()) / child.getHeight();
}
// Number of rows in this section.
final int section = mSectionIndexer.getSectionForPosition(firstVisibleItem);
final int sectionPos = mSectionIndexer.getPositionForSection(section);
final int sectionCount = mSections.length;
final int positionsInSection;
if (section < sectionCount - 1) {
final int nextSectionPos;
if (section + 1 < sectionCount) {
nextSectionPos = mSectionIndexer.getPositionForSection(section + 1);
} else {
nextSectionPos = totalItemCount - 1;
}
positionsInSection = nextSectionPos - sectionPos;
} else {
positionsInSection = totalItemCount - sectionPos;
}
// Position within this section.
final float posWithinSection;
if (positionsInSection == 0) {
posWithinSection = 0;
} else {
posWithinSection = (firstVisibleItem + incrementalPos - sectionPos)
/ positionsInSection;
}
return (section + posWithinSection) / sectionCount;
}
/**
* Cancels an ongoing fling event by injecting a
* {@link MotionEvent#ACTION_CANCEL} into the host view.
*/
private void cancelFling() {
final MotionEvent cancelFling = MotionEvent.obtain(
0, 0, MotionEvent.ACTION_CANCEL, 0, 0, 0);
mList.onTouchEvent(cancelFling);
cancelFling.recycle();
}
/**
* Cancels a pending drag.
*
* @see #startPendingDrag()
*/
private void cancelPendingDrag() {
mList.removeCallbacks(mDeferStartDrag);
mHasPendingDrag = false;
}
/**
* Delays dragging until after the framework has determined that the user is
* scrolling, rather than tapping.
*/
private void startPendingDrag() {
mHasPendingDrag = true;
mList.postDelayed(mDeferStartDrag, TAP_TIMEOUT);
}
private void beginDrag() {
setState(STATE_DRAGGING);
if (mListAdapter == null && mList != null) {
getSectionsFromIndexer();
}
if (mList != null) {
mList.requestDisallowInterceptTouchEvent(true);
mList.reportScrollStateChange(OnScrollListener.SCROLL_STATE_TOUCH_SCROLL);
}
cancelFling();
}
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (!isEnabled()) {
return false;
}
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
if (isPointInside(ev.getX(), ev.getY())) {
// If the parent has requested that its children delay
// pressed state (e.g. is a scrolling container) then we
// need to allow the parent time to decide whether it wants
// to intercept events. If it does, we will receive a CANCEL
// event.
if (!mList.isInScrollingContainer()) {
beginDrag();
return true;
}
mInitialTouchY = ev.getY();
startPendingDrag();
}
break;
case MotionEvent.ACTION_MOVE:
if (!isPointInside(ev.getX(), ev.getY())) {
cancelPendingDrag();
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
cancelPendingDrag();
break;
}
return false;
}
public boolean onInterceptHoverEvent(MotionEvent ev) {
if (!isEnabled()) {
return false;
}
final int actionMasked = ev.getActionMasked();
if ((actionMasked == MotionEvent.ACTION_HOVER_ENTER
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) && mState == STATE_NONE
&& isPointInside(ev.getX(), ev.getY())) {
setState(STATE_VISIBLE);
postAutoHide();
}
return false;
}
public boolean onTouchEvent(MotionEvent me) {
if (!isEnabled()) {
return false;
}
switch (me.getActionMasked()) {
case MotionEvent.ACTION_UP: {
if (mHasPendingDrag) {
// Allow a tap to scroll.
beginDrag();
final float pos = getPosFromMotionEvent(me.getY());
setThumbPos(pos);
scrollTo(pos);
cancelPendingDrag();
// Will hit the STATE_DRAGGING check below
}
if (mState == STATE_DRAGGING) {
if (mList != null) {
// ViewGroup does the right thing already, but there might
// be other classes that don't properly reset on touch-up,
// so do this explicitly just in case.
mList.requestDisallowInterceptTouchEvent(false);
mList.reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
}
setState(STATE_VISIBLE);
postAutoHide();
return true;
}
} break;
case MotionEvent.ACTION_MOVE: {
if (mHasPendingDrag && Math.abs(me.getY() - mInitialTouchY) > mScaledTouchSlop) {
setState(STATE_DRAGGING);
if (mListAdapter == null && mList != null) {
getSectionsFromIndexer();
}
if (mList != null) {
mList.requestDisallowInterceptTouchEvent(true);
mList.reportScrollStateChange(OnScrollListener.SCROLL_STATE_TOUCH_SCROLL);
}
cancelFling();
cancelPendingDrag();
// Will hit the STATE_DRAGGING check below
}
if (mState == STATE_DRAGGING) {
// TODO: Ignore jitter.
final float pos = getPosFromMotionEvent(me.getY());
setThumbPos(pos);
// If the previous scrollTo is still pending
if (mScrollCompleted) {
scrollTo(pos);
}
return true;
}
} break;
case MotionEvent.ACTION_CANCEL: {
cancelPendingDrag();
} break;
}
return false;
}
/**
* Returns whether a coordinate is inside the scroller's activation area. If
* there is a track image, touching anywhere within the thumb-width of the
* track activates scrolling. Otherwise, the user has to touch inside thumb
* itself.
*
* @param x The x-coordinate.
* @param y The y-coordinate.
* @return Whether the coordinate is inside the scroller's activation area.
*/
private boolean isPointInside(float x, float y) {
return isPointInsideX(x) && (mHasTrackImage || isPointInsideY(y));
}
private boolean isPointInsideX(float x) {
if (mLayoutFromRight) {
return x >= mThumbImage.getLeft();
} else {
return x <= mThumbImage.getRight();
}
}
private boolean isPointInsideY(float y) {
return y >= mThumbImage.getTop() && y <= mThumbImage.getBottom();
}
/**
* Constructs an animator for the specified property on a group of views.
* See {@link ObjectAnimator#ofFloat(Object, String, float...)} for
* implementation details.
*
* @param property The property being animated.
* @param value The value to which that property should animate.
* @param views The target views to animate.
* @return An animator for all the specified views.
*/
private static Animator groupAnimatorOfFloat(
Property property, float value, View... views) {
AnimatorSet animSet = new AnimatorSet();
AnimatorSet.Builder builder = null;
for (int i = views.length - 1; i >= 0; i--) {
final Animator anim = ObjectAnimator.ofFloat(views[i], property, value);
if (builder == null) {
builder = animSet.play(anim);
} else {
builder.with(anim);
}
}
return animSet;
}
/**
* Returns an animator for the view's scaleX value.
*/
private static Animator animateScaleX(View v, float target) {
return ObjectAnimator.ofFloat(v, View.SCALE_X, target);
}
/**
* Returns an animator for the view's alpha value.
*/
private static Animator animateAlpha(View v, float alpha) {
return ObjectAnimator.ofFloat(v, View.ALPHA, alpha);
}
/**
* A Property wrapper around the left
functionality handled by the
* {@link View#setLeft(int)} and {@link View#getLeft()} methods.
*/
private static Property LEFT = new IntProperty("left") {
@Override
public void setValue(View object, int value) {
object.setLeft(value);
}
@Override
public Integer get(View object) {
return object.getLeft();
}
};
/**
* A Property wrapper around the top
functionality handled by the
* {@link View#setTop(int)} and {@link View#getTop()} methods.
*/
private static Property TOP = new IntProperty("top") {
@Override
public void setValue(View object, int value) {
object.setTop(value);
}
@Override
public Integer get(View object) {
return object.getTop();
}
};
/**
* A Property wrapper around the right
functionality handled by the
* {@link View#setRight(int)} and {@link View#getRight()} methods.
*/
private static Property RIGHT = new IntProperty("right") {
@Override
public void setValue(View object, int value) {
object.setRight(value);
}
@Override
public Integer get(View object) {
return object.getRight();
}
};
/**
* A Property wrapper around the bottom
functionality handled by the
* {@link View#setBottom(int)} and {@link View#getBottom()} methods.
*/
private static Property BOTTOM = new IntProperty("bottom") {
@Override
public void setValue(View object, int value) {
object.setBottom(value);
}
@Override
public Integer get(View object) {
return object.getBottom();
}
};
/**
* Returns an animator for the view's bounds.
*/
private static Animator animateBounds(View v, Rect bounds) {
final PropertyValuesHolder left = PropertyValuesHolder.ofInt(LEFT, bounds.left);
final PropertyValuesHolder top = PropertyValuesHolder.ofInt(TOP, bounds.top);
final PropertyValuesHolder right = PropertyValuesHolder.ofInt(RIGHT, bounds.right);
final PropertyValuesHolder bottom = PropertyValuesHolder.ofInt(BOTTOM, bounds.bottom);
return ObjectAnimator.ofPropertyValuesHolder(v, left, top, right, bottom);
}
}