/* * 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.annotation.StyleRes; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.TypedArray; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.SystemClock; import android.text.TextUtils; 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.PointerIcon; 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 android.widget.ImageView.ScaleType; 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; // Positions for preview image and text. private static final int OVERLAY_FLOATING = 0; private static final int OVERLAY_AT_THUMB = 1; private static final int OVERLAY_ABOVE_THUMB = 2; // Positions for thumb in relation to track. private static final int THUMB_POSITION_MIDPOINT = 0; private static final int THUMB_POSITION_INSIDE = 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 View 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]; /** The minimum touch target size in pixels. */ private final int mMinimumTouchTarget; /** * Padding in pixels around the preview text. Applied as layout margins to * the preview text and padding to the preview image. */ private int mPreviewPadding; private int mPreviewMinWidth; private int mPreviewMinHeight; private int mThumbMinWidth; private int mThumbMinHeight; /** Theme-specified text size. Used only if text appearance is not set. */ private float mTextSize; /** Theme-specified text color. Used only if text appearance is not set. */ private ColorStateList mTextColor; private Drawable mThumbDrawable; private Drawable mTrackDrawable; private int mTextAppearance; private int mThumbPosition; // Used to convert between y-coordinate and thumb position within track. private float mThumbOffset; private float mThumbRange; /** Total width of decorations. */ private 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: * */ private int mState; /** Whether the preview image is visible. */ private boolean mShowingPreview; private Adapter 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: * */ 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 long mPendingDrag = -1; private int mScaledTouchSlop; private int mOldItemCount; private int mOldChildCount; /** * 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, int styleResId) { mList = listView; mOldItemCount = listView.getCount(); mOldChildCount = listView.getChildCount(); final Context context = listView.getContext(); mScaledTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); mScrollBarStyle = listView.getScrollBarStyle(); mScrollCompleted = true; mState = STATE_VISIBLE; mMatchDragPosition = context.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.HONEYCOMB; mTrackImage = new ImageView(context); mTrackImage.setScaleType(ScaleType.FIT_XY); mThumbImage = new ImageView(context); mThumbImage.setScaleType(ScaleType.FIT_XY); mPreviewImage = new View(context); mPreviewImage.setAlpha(0f); mPrimaryText = createPreviewTextView(context); mSecondaryText = createPreviewTextView(context); mMinimumTouchTarget = listView.getResources().getDimensionPixelSize( com.android.internal.R.dimen.fast_scroller_minimum_touch_target); setStyle(styleResId); final ViewGroupOverlay overlay = listView.getOverlay(); mOverlay = overlay; overlay.add(mTrackImage); overlay.add(mThumbImage); overlay.add(mPreviewImage); overlay.add(mPrimaryText); overlay.add(mSecondaryText); getSectionsFromIndexer(); updateLongList(mOldChildCount, mOldItemCount); setScrollbarPosition(listView.getVerticalScrollbarPosition()); postAutoHide(); } private void updateAppearance() { int width = 0; // Add track to overlay if it has an image. mTrackImage.setImageDrawable(mTrackDrawable); if (mTrackDrawable != null) { width = Math.max(width, mTrackDrawable.getIntrinsicWidth()); } // Add thumb to overlay if it has an image. mThumbImage.setImageDrawable(mThumbDrawable); mThumbImage.setMinimumWidth(mThumbMinWidth); mThumbImage.setMinimumHeight(mThumbMinHeight); if (mThumbDrawable != null) { width = Math.max(width, mThumbDrawable.getIntrinsicWidth()); } // Account for minimum thumb width. mWidth = Math.max(width, mThumbMinWidth); if (mTextAppearance != 0) { mPrimaryText.setTextAppearance(mTextAppearance); mSecondaryText.setTextAppearance(mTextAppearance); } if (mTextColor != null) { mPrimaryText.setTextColor(mTextColor); mSecondaryText.setTextColor(mTextColor); } if (mTextSize > 0) { mPrimaryText.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize); mSecondaryText.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize); } final int padding = mPreviewPadding; mPrimaryText.setIncludeFontPadding(false); mPrimaryText.setPadding(padding, padding, padding, padding); mSecondaryText.setIncludeFontPadding(false); mSecondaryText.setPadding(padding, padding, padding, padding); refreshDrawablePressedState(); } public void setStyle(@StyleRes int resId) { final Context context = mList.getContext(); final TypedArray ta = context.obtainStyledAttributes(null, R.styleable.FastScroll, R.attr.fastScrollStyle, resId); final int N = ta.getIndexCount(); for (int i = 0; i < N; i++) { final int index = ta.getIndex(i); switch (index) { case R.styleable.FastScroll_position: mOverlayPosition = ta.getInt(index, OVERLAY_FLOATING); break; case R.styleable.FastScroll_backgroundLeft: mPreviewResId[PREVIEW_LEFT] = ta.getResourceId(index, 0); break; case R.styleable.FastScroll_backgroundRight: mPreviewResId[PREVIEW_RIGHT] = ta.getResourceId(index, 0); break; case R.styleable.FastScroll_thumbDrawable: mThumbDrawable = ta.getDrawable(index); break; case R.styleable.FastScroll_trackDrawable: mTrackDrawable = ta.getDrawable(index); break; case R.styleable.FastScroll_textAppearance: mTextAppearance = ta.getResourceId(index, 0); break; case R.styleable.FastScroll_textColor: mTextColor = ta.getColorStateList(index); break; case R.styleable.FastScroll_textSize: mTextSize = ta.getDimensionPixelSize(index, 0); break; case R.styleable.FastScroll_minWidth: mPreviewMinWidth = ta.getDimensionPixelSize(index, 0); break; case R.styleable.FastScroll_minHeight: mPreviewMinHeight = ta.getDimensionPixelSize(index, 0); break; case R.styleable.FastScroll_thumbMinWidth: mThumbMinWidth = ta.getDimensionPixelSize(index, 0); break; case R.styleable.FastScroll_thumbMinHeight: mThumbMinHeight = ta.getDimensionPixelSize(index, 0); break; case R.styleable.FastScroll_padding: mPreviewPadding = ta.getDimensionPixelSize(index, 0); break; case R.styleable.FastScroll_thumbPosition: mThumbPosition = ta.getInt(index, THUMB_POSITION_MIDPOINT); break; } } ta.recycle(); updateAppearance(); } /** * 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(true); } } /** * @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(false); } } /** * @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. * * @param peekIfEnabled whether the thumb should peek, if enabled */ private void onStateDependencyChanged(boolean peekIfEnabled) { if (isEnabled()) { if (isAlwaysShowEnabled()) { setState(STATE_VISIBLE); } else if (mState == STATE_VISIBLE) { postAutoHide(); } else if (peekIfEnabled) { setState(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); // Propagate padding to text min width/height. final int textMinWidth = Math.max(0, mPreviewMinWidth - mPreviewImage.getPaddingLeft() - mPreviewImage.getPaddingRight()); mPrimaryText.setMinimumWidth(textMinWidth); mSecondaryText.setMinimumWidth(textMinWidth); final int textMinHeight = Math.max(0, mPreviewMinHeight - mPreviewImage.getPaddingTop() - mPreviewImage.getPaddingBottom()); mPrimaryText.setMinimumHeight(textMinHeight); mSecondaryText.setMinimumHeight(textMinHeight); // 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 childCount, int itemCount) { if (mOldItemCount != itemCount || mOldChildCount != childCount) { mOldItemCount = itemCount; mOldChildCount = childCount; final boolean hasMoreItems = itemCount - childCount > 0; if (hasMoreItems && mState != STATE_DRAGGING) { final int firstVisibleItem = mList.getFirstVisiblePosition(); setThumbPos(getPosFromItemCount(firstVisibleItem, childCount, itemCount)); } updateLongList(childCount, itemCount); } } private void updateLongList(int childCount, int itemCount) { final boolean longList = childCount > 0 && itemCount / childCount >= MIN_PAGES; if (mLongList != longList) { mLongList = longList; onStateDependencyChanged(false); } } /** * Creates a view into which preview text can be placed. */ private TextView createPreviewTextView(Context context) { final LayoutParams params = new LayoutParams( LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); final TextView textView = new TextView(context); textView.setLayoutParams(params); 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(); updateOffsetAndRange(); 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_FLOATING) { measureFloating(v, margins, out); } else { measureViewToSide(v, mThumbImage, 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 adjMaxHeight = Math.max(0, container.height()); final int adjMaxWidth = Math.max(0, maxWidth - marginLeft - marginRight); final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(adjMaxWidth, MeasureSpec.AT_MOST); final int heightMeasureSpec = MeasureSpec.makeSafeMeasureSpec( adjMaxHeight, MeasureSpec.UNSPECIFIED); view.measure(widthMeasureSpec, heightMeasureSpec); // Align to the left or right. final int width = Math.min(adjMaxWidth, 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 adjMaxHeight = Math.max(0, container.height()); final int adjMaxWidth = Math.max(0, containerWidth - marginLeft - marginRight); final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(adjMaxWidth, MeasureSpec.AT_MOST); final int heightMeasureSpec = MeasureSpec.makeSafeMeasureSpec( adjMaxHeight, 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 maxWidth = Math.max(0, container.width()); final int maxHeight = Math.max(0, container.height()); final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST); final int heightMeasureSpec = MeasureSpec.makeSafeMeasureSpec( maxHeight, MeasureSpec.UNSPECIFIED); track.measure(widthMeasureSpec, heightMeasureSpec); final int top; final int bottom; if (mThumbPosition == THUMB_POSITION_INSIDE) { top = container.top; bottom = container.bottom; } else { final int thumbHalfHeight = thumb.getHeight() / 2; top = container.top + thumbHalfHeight; bottom = container.bottom - thumbHalfHeight; } final int trackWidth = track.getMeasuredWidth(); final int left = thumb.getLeft() + (thumb.getWidth() - trackWidth) / 2; final int right = left + trackWidth; track.layout(left, top, right, bottom); } /** * Updates the offset and range used to convert from absolute y-position to * thumb position within the track. */ private void updateOffsetAndRange() { final View trackImage = mTrackImage; final View thumbImage = mThumbImage; final float min; final float max; if (mThumbPosition == THUMB_POSITION_INSIDE) { final float halfThumbHeight = thumbImage.getHeight() / 2f; min = trackImage.getTop() + halfThumbHeight; max = trackImage.getBottom() - halfThumbHeight; } else{ min = trackImage.getTop(); max = trackImage.getBottom(); } mThumbOffset = min; mThumbRange = max - min; } 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(); mShowingPreview = false; } /** * 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(); mShowingPreview = false; } /** * 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(); mShowingPreview = true; } 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 = adapter; mSections = mSectionIndexer.getSections(); } } else if (adapter instanceof SectionIndexer) { mListAdapter = adapter; mSectionIndexer = (SectionIndexer) adapter; mSections = mSectionIndexer.getSections(); } else { mListAdapter = 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; final boolean hasPreview = transitionPreviewLayout(sectionIndex); if (!mShowingPreview && hasPreview) { transitionToDragging(); } else if (mShowingPreview && !hasPreview) { 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 View 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 -= preview.getPaddingLeft(); bounds.top -= preview.getPaddingTop(); bounds.right += preview.getPaddingRight(); bounds.bottom += preview.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 !TextUtils.isEmpty(text); } /** * 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 float thumbMiddle = position * mThumbRange + mThumbOffset; mThumbImage.setTranslationY(thumbMiddle - mThumbImage.getHeight() / 2f); final View previewImage = mPreviewImage; final float previewHalfHeight = previewImage.getHeight() / 2f; final float previewPos; switch (mOverlayPosition) { case OVERLAY_AT_THUMB: previewPos = thumbMiddle; break; case OVERLAY_ABOVE_THUMB: previewPos = thumbMiddle - previewHalfHeight; break; case OVERLAY_FLOATING: default: previewPos = 0; break; } // Center the preview on the thumb, constrained to the list bounds. final Rect container = mContainerRect; final int top = container.top; final int bottom = container.bottom; final float minP = top + previewHalfHeight; final float maxP = bottom - previewHalfHeight; final float previewMiddle = MathUtils.constrain(previewPos, minP, maxP); final float previewTop = previewMiddle - previewHalfHeight; previewImage.setTranslationY(previewTop); mPrimaryText.setTranslationY(previewTop); mSecondaryText.setTranslationY(previewTop); } private float getPosFromMotionEvent(float y) { // If the list is the same height as the thumbnail or shorter, // effectively disable scrolling. if (mThumbRange <= 0) { return 0f; } return MathUtils.constrain((y - mThumbOffset) / mThumbRange, 0f, 1f); } /** * Calculates the thumb position based on the visible items. * * @param firstVisibleItem First visible item, >= 0. * @param visibleItemCount Number of visible items, >= 0. * @param totalItemCount Total number of items, >= 0. * @return */ private float getPosFromItemCount( int firstVisibleItem, int visibleItemCount, int totalItemCount) { final SectionIndexer sectionIndexer = mSectionIndexer; if (sectionIndexer == null || mListAdapter == null) { getSectionsFromIndexer(); } if (visibleItemCount == 0 || totalItemCount == 0) { // No items are visible. return 0; } final boolean hasSections = sectionIndexer != null && mSections != null && mSections.length > 0; if (!hasSections || !mMatchDragPosition) { if (visibleItemCount == totalItemCount) { // All items are visible. return 0; } else { 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 = sectionIndexer.getSectionForPosition(firstVisibleItem); final int sectionPos = sectionIndexer.getPositionForSection(section); final int sectionCount = mSections.length; final int positionsInSection; if (section < sectionCount - 1) { final int nextSectionPos; if (section + 1 < sectionCount) { nextSectionPos = sectionIndexer.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; } float result = (section + posWithinSection) / sectionCount; // Fake out the scroll bar for the last item. Since the section indexer // won't ever actually move the list in this end space, make scrolling // across the last item account for whatever space is remaining. if (firstVisibleItem > 0 && firstVisibleItem + visibleItemCount == totalItemCount) { final View lastChild = mList.getChildAt(visibleItemCount - 1); final int bottomPadding = mList.getPaddingBottom(); final int maxSize; final int currentVisibleSize; if (mList.getClipToPadding()) { maxSize = lastChild.getHeight(); currentVisibleSize = mList.getHeight() - bottomPadding - lastChild.getTop(); } else { maxSize = lastChild.getHeight() + bottomPadding; currentVisibleSize = mList.getHeight() - lastChild.getTop(); } if (currentVisibleSize > 0 && maxSize > 0) { result += (1 - result) * ((float) currentVisibleSize / maxSize ); } } return result; } /** * 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() { mPendingDrag = -1; } /** * Delays dragging until after the framework has determined that the user is * scrolling, rather than tapping. */ private void startPendingDrag() { mPendingDrag = SystemClock.uptimeMillis() + TAP_TIMEOUT; } private void beginDrag() { mPendingDrag = -1; 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()) { // This will get dispatched to onTouchEvent(). Start // dragging there. return true; } mInitialTouchY = ev.getY(); startPendingDrag(); } break; case MotionEvent.ACTION_MOVE: if (!isPointInside(ev.getX(), ev.getY())) { cancelPendingDrag(); } else if (mPendingDrag >= 0 && mPendingDrag <= SystemClock.uptimeMillis()) { beginDrag(); final float pos = getPosFromMotionEvent(mInitialTouchY); scrollTo(pos); // This may get dispatched to onTouchEvent(), but it // doesn't really matter since we'll already be in a drag. return onTouchEvent(ev); } 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 PointerIcon onResolvePointerIcon(MotionEvent event, int pointerIndex) { if (mState == STATE_DRAGGING || isPointInside(event.getX(), event.getY())) { return PointerIcon.getSystemIcon(mList.getContext(), PointerIcon.TYPE_ARROW); } return null; } public boolean onTouchEvent(MotionEvent me) { if (!isEnabled()) { return false; } switch (me.getActionMasked()) { case MotionEvent.ACTION_DOWN: { if (isPointInside(me.getX(), me.getY())) { if (!mList.isInScrollingContainer()) { beginDrag(); return true; } } } break; case MotionEvent.ACTION_UP: { if (mPendingDrag >= 0) { // Allow a tap to scroll. beginDrag(); final float pos = getPosFromMotionEvent(me.getY()); setThumbPos(pos); scrollTo(pos); // 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 (mPendingDrag >= 0 && Math.abs(me.getY() - mInitialTouchY) > mScaledTouchSlop) { beginDrag(); // 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) && (mTrackDrawable != null || isPointInsideY(y)); } private boolean isPointInsideX(float x) { final float offset = mThumbImage.getTranslationX(); final float left = mThumbImage.getLeft() + offset; final float right = mThumbImage.getRight() + offset; // Apply the minimum touch target size. final float targetSizeDiff = mMinimumTouchTarget - (right - left); final float adjust = targetSizeDiff > 0 ? targetSizeDiff : 0; if (mLayoutFromRight) { return x >= mThumbImage.getLeft() - adjust; } else { return x <= mThumbImage.getRight() + adjust; } } private boolean isPointInsideY(float y) { final float offset = mThumbImage.getTranslationY(); final float top = mThumbImage.getTop() + offset; final float bottom = mThumbImage.getBottom() + offset; // Apply the minimum touch target size. final float targetSizeDiff = mMinimumTouchTarget - (bottom - top); final float adjust = targetSizeDiff > 0 ? targetSizeDiff / 2 : 0; return y >= (top - adjust) && y <= (bottom + adjust); } /** * 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); } }