Java tutorial
/* * The MIT License Copyright (c) 2011 Paul Soucy (paul@dev-smart.com) * The MIT License Copyright (c) 2013 MeetMe, Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and * associated documentation files (the "Software"), to deal in the Software without restriction, * including without limitation the rights to use, copy, modify, merge, publish, distribute, * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all copies or * substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ // @formatter:off /* * This is based on HorizontalListView.java from: https://github.com/dinocore1/DevsmartLib-Android * It has been substantially rewritten and added to from the original version. */ // @formatter:on package com.klower.horizonallistview; import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import java.util.Queue; import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.content.Context; import android.content.res.TypedArray; import android.database.DataSetObserver; import android.graphics.Canvas; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Bundle; import android.os.Parcelable; import android.support.v4.view.ViewCompat; import android.support.v4.widget.EdgeEffectCompat; import android.util.AttributeSet; import android.view.GestureDetector; import android.view.HapticFeedbackConstants; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.ListAdapter; import android.widget.ListView; import android.widget.ScrollView; import android.widget.Scroller; import com.klower.R; // @formatter:off /** * A view that shows items in a horizontally scrolling list. The items * come from the {@link ListAdapter} associated with this view. <br> * <br> * <b>Limitations:</b> * <ul> * <li>Does not support keyboard navigation</li> * <li>Does not support scroll bars<li> * <li>Does not support header or footer views<li> * <li>Does not support disabled items<li> * </ul> * <br> * <b>Custom XML Parameters Supported:</b><br> * <br> * <ul> * <li><b>divider</b> - The divider to use between items. This can be a color or a drawable. If a drawable is used * dividerWidth will automatically be set to the intrinsic width of the provided drawable, this can be overriden by providing a dividerWidth.</li> * <li><b>dividerWidth</b> - The width of the divider to be drawn.</li> * <li><b>android:requiresFadingEdge</b> - If horizontal fading edges are enabled this view will render them</li> * <li><b>android:fadingEdgeLength</b> - The length of the horizontal fading edges</li> * </ul> */ // @formatter:on public class HorizontalListView extends AdapterView<ListAdapter> { /** Defines where to insert items into the ViewGroup, as defined in {@code ViewGroup #addViewInLayout(View, int, LayoutParams, boolean)} */ private static final int INSERT_AT_END_OF_LIST = -1; private static final int INSERT_AT_START_OF_LIST = 0; /** The velocity to use for overscroll absorption */ private static final float FLING_DEFAULT_ABSORB_VELOCITY = 30f; /** The friction amount to use for the fling tracker */ private static final float FLING_FRICTION = 0.009f; /** Used for tracking the state data necessary to restore the HorizontalListView to its previous state after a rotation occurs */ private static final String BUNDLE_ID_CURRENT_X = "BUNDLE_ID_CURRENT_X"; /** The bundle id of the parents state. Used to restore the parent's state after a rotation occurs */ private static final String BUNDLE_ID_PARENT_STATE = "BUNDLE_ID_PARENT_STATE"; /** Tracks ongoing flings */ protected Scroller mFlingTracker = new Scroller(getContext()); /** Gesture listener to receive callbacks when gestures are detected */ private final GestureListener mGestureListener = new GestureListener(); /** Used for detecting gestures within this view so they can be handled */ private GestureDetector mGestureDetector; /** This tracks the starting layout position of the leftmost view */ private int mDisplayOffset; /** Holds a reference to the adapter bound to this view */ protected ListAdapter mAdapter; /** Holds a cache of recycled views to be reused as needed */ private List<Queue<View>> mRemovedViewsCache = new ArrayList<Queue<View>>(); /** Flag used to mark when the adapters data has changed, so the view can be relaid out */ private boolean mDataChanged = false; /** Temporary rectangle to be used for measurements */ private Rect mRect = new Rect(); /** Tracks the currently touched view, used to delegate touches to the view being touched */ private View mViewBeingTouched = null; /** The width of the divider that will be used between list items */ private int mDividerWidth = 0; /** The drawable that will be used as the list divider */ private Drawable mDivider = null; /** The x position of the currently rendered view */ protected int mCurrentX; /** The x position of the next to be rendered view */ protected int mNextX; /** Used to hold the scroll position to restore to post rotate */ private Integer mRestoreX = null; /** Tracks the maximum possible X position, stays at max value until last item is laid out and it can be determined */ private int mMaxX = Integer.MAX_VALUE; /** The adapter index of the leftmost view currently visible */ private int mLeftViewAdapterIndex; /** The adapter index of the rightmost view currently visible */ private int mRightViewAdapterIndex; /** This tracks the currently selected accessibility item */ private int mCurrentlySelectedAdapterIndex; /** * Callback interface to notify listener that the user has scrolled this view to the point that it is low on data. */ private RunningOutOfDataListener mRunningOutOfDataListener = null; /** * This tracks the user value set of how many items from the end will be considered running out of data. */ private int mRunningOutOfDataThreshold = 0; /** * Tracks if we have told the listener that we are running low on data. We only want to tell them once. */ private boolean mHasNotifiedRunningLowOnData = false; /** * Callback interface to be invoked when the scroll state has changed. */ private OnScrollStateChangedListener mOnScrollStateChangedListener = null; /** * Represents the current scroll state of this view. Needed so we can detect when the state changes so scroll listener can be notified. */ private OnScrollStateChangedListener.ScrollState mCurrentScrollState = OnScrollStateChangedListener.ScrollState.SCROLL_STATE_IDLE; /** * Tracks the state of the left edge glow. */ private EdgeEffectCompat mEdgeGlowLeft; /** * Tracks the state of the right edge glow. */ private EdgeEffectCompat mEdgeGlowRight; /** The height measure spec for this view, used to help size children views */ private int mHeightMeasureSpec; /** Used to track if a view touch should be blocked because it stopped a fling */ private boolean mBlockTouchAction = false; /** Used to track if the parent vertically scrollable view has been told to DisallowInterceptTouchEvent */ private boolean mIsParentVerticiallyScrollableViewDisallowingInterceptTouchEvent = false; /** * The listener that receives notifications when this view is clicked. */ private OnClickListener mOnClickListener; public HorizontalListView(Context context, AttributeSet attrs) { super(context, attrs); mEdgeGlowLeft = new EdgeEffectCompat(context); mEdgeGlowRight = new EdgeEffectCompat(context); mGestureDetector = new GestureDetector(context, mGestureListener); bindGestureDetector(); initView(); retrieveXmlConfiguration(context, attrs); setWillNotDraw(false); // If the OS version is high enough then set the friction on the fling tracker */ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { HoneycombPlus.setFriction(mFlingTracker, FLING_FRICTION); } } /** Registers the gesture detector to receive gesture notifications for this view */ private void bindGestureDetector() { // Generic touch listener that can be applied to any view that needs to process gestures final OnTouchListener gestureListenerHandler = new OnTouchListener() { @Override public boolean onTouch(final View v, final MotionEvent event) { // Delegate the touch event to our gesture detector return mGestureDetector.onTouchEvent(event); } }; setOnTouchListener(gestureListenerHandler); } /** * When this HorizontalListView is embedded within a vertical scrolling view it is important to disable the parent view from interacting with * any touch events while the user is scrolling within this HorizontalListView. This will start at this view and go up the view tree looking * for a vertical scrolling view. If one is found it will enable or disable parent touch interception. * * @param disallowIntercept If true the parent will be prevented from intercepting child touch events */ private void requestParentListViewToNotInterceptTouchEvents(Boolean disallowIntercept) { // Prevent calling this more than once needlessly if (mIsParentVerticiallyScrollableViewDisallowingInterceptTouchEvent != disallowIntercept) { View view = this; while (view.getParent() instanceof View) { // If the parent is a ListView or ScrollView then disallow intercepting of touch events if (view.getParent() instanceof ListView || view.getParent() instanceof ScrollView) { view.getParent().requestDisallowInterceptTouchEvent(disallowIntercept); mIsParentVerticiallyScrollableViewDisallowingInterceptTouchEvent = disallowIntercept; return; } view = (View) view.getParent(); } } } /** * Parse the XML configuration for this widget * * @param context Context used for extracting attributes * @param attrs The Attribute Set containing the ColumnView attributes */ private void retrieveXmlConfiguration(Context context, AttributeSet attrs) { if (attrs != null) { TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.HorizontalListView); // Get the provided drawable from the XML final Drawable d = a.getDrawable(R.styleable.HorizontalListView_android_divider); if (d != null) { // If a drawable is provided to use as the divider then use its intrinsic width for the divider width setDivider(d); } // If a width is explicitly specified then use that width final int dividerWidth = a.getDimensionPixelSize(R.styleable.HorizontalListView_dividerWidth, 0); if (dividerWidth != 0) { setDividerWidth(dividerWidth); } a.recycle(); } } @Override public Parcelable onSaveInstanceState() { Bundle bundle = new Bundle(); // Add the parent state to the bundle bundle.putParcelable(BUNDLE_ID_PARENT_STATE, super.onSaveInstanceState()); // Add our state to the bundle bundle.putInt(BUNDLE_ID_CURRENT_X, mCurrentX); return bundle; } @Override public void onRestoreInstanceState(Parcelable state) { if (state instanceof Bundle) { Bundle bundle = (Bundle) state; // Restore our state from the bundle mRestoreX = Integer.valueOf((bundle.getInt(BUNDLE_ID_CURRENT_X))); // Restore out parent's state from the bundle super.onRestoreInstanceState(bundle.getParcelable(BUNDLE_ID_PARENT_STATE)); } } /** * Sets the drawable that will be drawn between each item in the list. If the drawable does * not have an intrinsic width, you should also call {@link #setDividerWidth(int)} * * @param divider The drawable to use. */ public void setDivider(Drawable divider) { mDivider = divider; if (divider != null) { setDividerWidth(divider.getIntrinsicWidth()); } else { setDividerWidth(0); } } /** * Sets the width of the divider that will be drawn between each item in the list. Calling * this will override the intrinsic width as set by {@link #setDivider(Drawable)} * * @param width The width of the divider in pixels. */ public void setDividerWidth(int width) { mDividerWidth = width; // Force the view to rerender itself requestLayout(); invalidate(); } private void initView() { mLeftViewAdapterIndex = -1; mRightViewAdapterIndex = -1; mDisplayOffset = 0; mCurrentX = 0; mNextX = 0; mMaxX = Integer.MAX_VALUE; setCurrentScrollState(OnScrollStateChangedListener.ScrollState.SCROLL_STATE_IDLE); } /** Will re-initialize the HorizontalListView to remove all child views rendered and reset to initial configuration. */ private void reset() { initView(); removeAllViewsInLayout(); requestLayout(); } /** DataSetObserver used to capture adapter data change events */ private DataSetObserver mAdapterDataObserver = new DataSetObserver() { @Override public void onChanged() { mDataChanged = true; // Clear so we can notify again as we run out of data mHasNotifiedRunningLowOnData = false; unpressTouchedChild(); // Invalidate and request layout to force this view to completely redraw itself invalidate(); requestLayout(); } @Override public void onInvalidated() { // Clear so we can notify again as we run out of data mHasNotifiedRunningLowOnData = false; unpressTouchedChild(); reset(); // Invalidate and request layout to force this view to completely redraw itself invalidate(); requestLayout(); } }; @Override public void setSelection(int position) { mCurrentlySelectedAdapterIndex = position; } @Override public View getSelectedView() { return getChild(mCurrentlySelectedAdapterIndex); } @Override public void setAdapter(ListAdapter adapter) { if (mAdapter != null) { mAdapter.unregisterDataSetObserver(mAdapterDataObserver); } if (adapter != null) { // Clear so we can notify again as we run out of data mHasNotifiedRunningLowOnData = false; mAdapter = adapter; mAdapter.registerDataSetObserver(mAdapterDataObserver); } initializeRecycledViewCache(mAdapter.getViewTypeCount()); reset(); } @Override public ListAdapter getAdapter() { return mAdapter; } /** * Will create and initialize a cache for the given number of different types of views. * * @param viewTypeCount - The total number of different views supported */ private void initializeRecycledViewCache(int viewTypeCount) { // The cache is created such that the response from mAdapter.getItemViewType is the array index to the correct cache for that item. mRemovedViewsCache.clear(); for (int i = 0; i < viewTypeCount; i++) { mRemovedViewsCache.add(new LinkedList<View>()); } } /** * Returns a recycled view from the cache that can be reused, or null if one is not available. * * @param adapterIndex * @return */ private View getRecycledView(int adapterIndex) { int itemViewType = mAdapter.getItemViewType(adapterIndex); if (isItemViewTypeValid(itemViewType)) { return mRemovedViewsCache.get(itemViewType).poll(); } return null; } /** * Adds the provided view to a recycled views cache. * * @param adapterIndex * @param view */ private void recycleView(int adapterIndex, View view) { // There is one Queue of views for each different type of view. // Just add the view to the pile of other views of the same type. // The order they are added and removed does not matter. int itemViewType = mAdapter.getItemViewType(adapterIndex); if (isItemViewTypeValid(itemViewType)) { mRemovedViewsCache.get(itemViewType).offer(view); } } private boolean isItemViewTypeValid(int itemViewType) { return itemViewType < mRemovedViewsCache.size(); } /** Adds a child to this viewgroup and measures it so it renders the correct size */ private void addAndMeasureChild(final View child, int viewPos) { LayoutParams params = getLayoutParams(child); addViewInLayout(child, viewPos, params, true); measureChild(child); } /** * Measure the provided child. * * @param child The child. */ private void measureChild(View child) { LayoutParams childLayoutParams = getLayoutParams(child); int childHeightSpec = ViewGroup.getChildMeasureSpec(mHeightMeasureSpec, getPaddingTop() + getPaddingBottom(), childLayoutParams.height); int childWidthSpec; if (childLayoutParams.width > 0) { childWidthSpec = MeasureSpec.makeMeasureSpec(childLayoutParams.width, MeasureSpec.EXACTLY); } else { childWidthSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); } child.measure(childWidthSpec, childHeightSpec); } /** Gets a child's layout parameters, defaults if not available. */ private LayoutParams getLayoutParams(View child) { LayoutParams layoutParams = child.getLayoutParams(); if (layoutParams == null) { // Since this is a horizontal list view default to matching the parents height, and wrapping the width layoutParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT); } return layoutParams; } @SuppressLint("WrongCall") @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); if (mAdapter == null) { return; } // Force the OS to redraw this view invalidate(); // If the data changed then reset everything and render from scratch at the same offset as last time if (mDataChanged) { int oldCurrentX = mCurrentX; initView(); removeAllViewsInLayout(); mNextX = oldCurrentX; mDataChanged = false; } // If restoring from a rotation if (mRestoreX != null) { mNextX = mRestoreX; mRestoreX = null; } // If in a fling if (mFlingTracker.computeScrollOffset()) { // Compute the next position mNextX = mFlingTracker.getCurrX(); } // Prevent scrolling past 0 so you can't scroll past the end of the list to the left if (mNextX < 0) { mNextX = 0; // Show an edge effect absorbing the current velocity if (mEdgeGlowLeft.isFinished()) { mEdgeGlowLeft.onAbsorb((int) determineFlingAbsorbVelocity()); } mFlingTracker.forceFinished(true); setCurrentScrollState(OnScrollStateChangedListener.ScrollState.SCROLL_STATE_IDLE); } else if (mNextX > mMaxX) { // Clip the maximum scroll position at mMaxX so you can't scroll past the end of the list to the right mNextX = mMaxX; // Show an edge effect absorbing the current velocity if (mEdgeGlowRight.isFinished()) { mEdgeGlowRight.onAbsorb((int) determineFlingAbsorbVelocity()); } mFlingTracker.forceFinished(true); setCurrentScrollState(OnScrollStateChangedListener.ScrollState.SCROLL_STATE_IDLE); } // Calculate our delta from the last time the view was drawn int dx = mCurrentX - mNextX; removeNonVisibleChildren(dx); fillList(dx); positionChildren(dx); // Since the view has now been drawn, update our current position mCurrentX = mNextX; // If we have scrolled enough to lay out all views, then determine the maximum scroll position now if (determineMaxX()) { // Redo the layout pass since we now know the maximum scroll position onLayout(changed, left, top, right, bottom); return; } // If the fling has finished if (mFlingTracker.isFinished()) { // If the fling just ended if (mCurrentScrollState == OnScrollStateChangedListener.ScrollState.SCROLL_STATE_FLING) { setCurrentScrollState(OnScrollStateChangedListener.ScrollState.SCROLL_STATE_IDLE); } } else { // Still in a fling so schedule the next frame ViewCompat.postOnAnimation(this, mDelayedLayout); } } @Override protected float getLeftFadingEdgeStrength() { int horizontalFadingEdgeLength = getHorizontalFadingEdgeLength(); // If completely at the edge then disable the fading edge if (mCurrentX == 0) { return 0; } else if (mCurrentX < horizontalFadingEdgeLength) { // We are very close to the edge, so enable the fading edge proportional to the distance from the edge, and the width of the edge effect return (float) mCurrentX / horizontalFadingEdgeLength; } else { // The current x position is more then the width of the fading edge so enable it fully. return 1; } } @Override protected float getRightFadingEdgeStrength() { int horizontalFadingEdgeLength = getHorizontalFadingEdgeLength(); // If completely at the edge then disable the fading edge if (mCurrentX == mMaxX) { return 0; } else if ((mMaxX - mCurrentX) < horizontalFadingEdgeLength) { // We are very close to the edge, so enable the fading edge proportional to the distance from the ednge, and the width of the edge effect return (float) (mMaxX - mCurrentX) / horizontalFadingEdgeLength; } else { // The distance from the maximum x position is more then the width of the fading edge so enable it fully. return 1; } } /** Determines the current fling absorb velocity */ private float determineFlingAbsorbVelocity() { // If the OS version is high enough get the real velocity */ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { return IceCreamSandwichPlus.getCurrVelocity(mFlingTracker); } else { // Unable to get the velocity so just return a default. // In actuality this is never used since EdgeEffectCompat does not draw anything unless the device is ICS+. // Less then ICS EdgeEffectCompat essentially performs a NOP. return FLING_DEFAULT_ABSORB_VELOCITY; } } /** Use to schedule a request layout via a runnable */ private Runnable mDelayedLayout = new Runnable() { @Override public void run() { requestLayout(); } }; @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); // Cache off the measure spec mHeightMeasureSpec = heightMeasureSpec; }; /** * Determine the Max X position. This is the farthest that the user can scroll the screen. Until the last adapter item has been * laid out it is impossible to calculate; once that has occurred this will perform the calculation, and if necessary force a * redraw and relayout of this view. * * @return true if the maxx position was just determined */ private boolean determineMaxX() { // If the last view has been laid out, then we can determine the maximum x position if (isLastItemInAdapter(mRightViewAdapterIndex)) { View rightView = getRightmostChild(); if (rightView != null) { int oldMaxX = mMaxX; // Determine the maximum x position mMaxX = mCurrentX + (rightView.getRight() - getPaddingLeft()) - getRenderWidth(); // Handle the case where the views do not fill at least 1 screen if (mMaxX < 0) { mMaxX = 0; } if (mMaxX != oldMaxX) { return true; } } } return false; } /** Adds children views to the left and right of the current views until the screen is full */ private void fillList(final int dx) { // Get the rightmost child and determine its right edge int edge = 0; View child = getRightmostChild(); if (child != null) { edge = child.getRight(); } // Add new children views to the right, until past the edge of the screen fillListRight(edge, dx); // Get the leftmost child and determine its left edge edge = 0; child = getLeftmostChild(); if (child != null) { edge = child.getLeft(); } // Add new children views to the left, until past the edge of the screen fillListLeft(edge, dx); } private void removeNonVisibleChildren(final int dx) { View child = getLeftmostChild(); // Loop removing the leftmost child, until that child is on the screen while (child != null && child.getRight() + dx <= 0) { // The child is being completely removed so remove its width from the display offset and its divider if it has one. // To remove add the size of the child and its divider (if it has one) to the offset. // You need to add since its being removed from the left side, i.e. shifting the offset to the right. mDisplayOffset += isLastItemInAdapter(mLeftViewAdapterIndex) ? child.getMeasuredWidth() : mDividerWidth + child.getMeasuredWidth(); // Add the removed view to the cache recycleView(mLeftViewAdapterIndex, child); // Actually remove the view removeViewInLayout(child); // Keep track of the adapter index of the left most child mLeftViewAdapterIndex++; // Get the new leftmost child child = getLeftmostChild(); } child = getRightmostChild(); // Loop removing the rightmost child, until that child is on the screen while (child != null && child.getLeft() + dx >= getWidth()) { recycleView(mRightViewAdapterIndex, child); removeViewInLayout(child); mRightViewAdapterIndex--; child = getRightmostChild(); } } private void fillListRight(int rightEdge, final int dx) { // Loop adding views to the right until the screen is filled while (rightEdge + dx + mDividerWidth < getWidth() && mRightViewAdapterIndex + 1 < mAdapter.getCount()) { mRightViewAdapterIndex++; // If mLeftViewAdapterIndex < 0 then this is the first time a view is being added, and left == right if (mLeftViewAdapterIndex < 0) { mLeftViewAdapterIndex = mRightViewAdapterIndex; } // Get the view from the adapter, utilizing a cached view if one is available View child = mAdapter.getView(mRightViewAdapterIndex, getRecycledView(mRightViewAdapterIndex), this); addAndMeasureChild(child, INSERT_AT_END_OF_LIST); // If first view, then no divider to the left of it, otherwise add the space for the divider width rightEdge += (mRightViewAdapterIndex == 0 ? 0 : mDividerWidth) + child.getMeasuredWidth(); // Check if we are running low on data so we can tell listeners to go get more determineIfLowOnData(); } } private void fillListLeft(int leftEdge, final int dx) { // Loop adding views to the left until the screen is filled while (leftEdge + dx - mDividerWidth > 0 && mLeftViewAdapterIndex >= 1) { mLeftViewAdapterIndex--; View child = mAdapter.getView(mLeftViewAdapterIndex, getRecycledView(mLeftViewAdapterIndex), this); addAndMeasureChild(child, INSERT_AT_START_OF_LIST); // If first view, then no divider to the left of it leftEdge -= mLeftViewAdapterIndex == 0 ? child.getMeasuredWidth() : mDividerWidth + child.getMeasuredWidth(); // If on a clean edge then just remove the child, otherwise remove the divider as well mDisplayOffset -= leftEdge + dx == 0 ? child.getMeasuredWidth() : mDividerWidth + child.getMeasuredWidth(); } } /** Loops through each child and positions them onto the screen */ private void positionChildren(final int dx) { int childCount = getChildCount(); if (childCount > 0) { mDisplayOffset += dx; int leftOffset = mDisplayOffset; // Loop each child view for (int i = 0; i < childCount; i++) { View child = getChildAt(i); int left = leftOffset + getPaddingLeft(); int top = getPaddingTop(); int right = left + child.getMeasuredWidth(); int bottom = top + child.getMeasuredHeight(); // Layout the child child.layout(left, top, right, bottom); // Increment our offset by added child's size and divider width leftOffset += child.getMeasuredWidth() + mDividerWidth; } } } /** Gets the current child that is leftmost on the screen. */ private View getLeftmostChild() { return getChildAt(0); } /** Gets the current child that is rightmost on the screen. */ private View getRightmostChild() { return getChildAt(getChildCount() - 1); } /** * Finds a child view that is contained within this view, given the adapter index. * @return View The child view, or or null if not found. */ private View getChild(int adapterIndex) { if (adapterIndex >= mLeftViewAdapterIndex && adapterIndex <= mRightViewAdapterIndex) { return getChildAt(adapterIndex - mLeftViewAdapterIndex); } return null; } /** * Returns the index of the child that contains the coordinates given. * This is useful to determine which child has been touched. * This can be used for a call to {@link #getChildAt(int)} * * @param x X-coordinate * @param y Y-coordinate * @return The index of the child that contains the coordinates. If no child is found then returns -1 */ private int getChildIndex(final int x, final int y) { int childCount = getChildCount(); for (int index = 0; index < childCount; index++) { getChildAt(index).getHitRect(mRect); if (mRect.contains(x, y)) { return index; } } return -1; } /** Simple convenience method for determining if this index is the last index in the adapter */ private boolean isLastItemInAdapter(int index) { return index == mAdapter.getCount() - 1; } /** Gets the height in px this view will be rendered. (padding removed) */ private int getRenderHeight() { return getHeight() - getPaddingTop() - getPaddingBottom(); } /** Gets the width in px this view will be rendered. (padding removed) */ private int getRenderWidth() { return getWidth() - getPaddingLeft() - getPaddingRight(); } /** Scroll to the provided offset */ public void scrollTo(int x) { mFlingTracker.startScroll(mNextX, 0, x - mNextX, 0); setCurrentScrollState(OnScrollStateChangedListener.ScrollState.SCROLL_STATE_FLING); requestLayout(); } @Override public int getFirstVisiblePosition() { return mLeftViewAdapterIndex; } @Override public int getLastVisiblePosition() { return mRightViewAdapterIndex; } /** Draws the overscroll edge glow effect on the left and right sides of the horizontal list */ private void drawEdgeGlow(Canvas canvas) { if (mEdgeGlowLeft != null && !mEdgeGlowLeft.isFinished() && isEdgeGlowEnabled()) { // The Edge glow is meant to come from the top of the screen, so rotate it to draw on the left side. final int restoreCount = canvas.save(); final int height = getHeight(); canvas.rotate(-90, 0, 0); canvas.translate(-height + getPaddingBottom(), 0); mEdgeGlowLeft.setSize(getRenderHeight(), getRenderWidth()); if (mEdgeGlowLeft.draw(canvas)) { invalidate(); } canvas.restoreToCount(restoreCount); } else if (mEdgeGlowRight != null && !mEdgeGlowRight.isFinished() && isEdgeGlowEnabled()) { // The Edge glow is meant to come from the top of the screen, so rotate it to draw on the right side. final int restoreCount = canvas.save(); final int width = getWidth(); canvas.rotate(90, 0, 0); canvas.translate(getPaddingTop(), -width); mEdgeGlowRight.setSize(getRenderHeight(), getRenderWidth()); if (mEdgeGlowRight.draw(canvas)) { invalidate(); } canvas.restoreToCount(restoreCount); } } /** Draws the dividers that go in between the horizontal list view items */ private void drawDividers(Canvas canvas) { final int count = getChildCount(); // Only modify the left and right in the loop, we set the top and bottom here since they are always the same final Rect bounds = mRect; mRect.top = getPaddingTop(); mRect.bottom = mRect.top + getRenderHeight(); // Draw the list dividers for (int i = 0; i < count; i++) { // Don't draw a divider to the right of the last item in the adapter if (!(i == count - 1 && isLastItemInAdapter(mRightViewAdapterIndex))) { View child = getChildAt(i); bounds.left = child.getRight(); bounds.right = child.getRight() + mDividerWidth; // Clip at the left edge of the screen if (bounds.left < getPaddingLeft()) { bounds.left = getPaddingLeft(); } // Clip at the right edge of the screen if (bounds.right > getWidth() - getPaddingRight()) { bounds.right = getWidth() - getPaddingRight(); } // Draw a divider to the right of the child drawDivider(canvas, bounds); // If the first view, determine if a divider should be shown to the left of it. // A divider should be shown if the left side of this view does not fill to the left edge of the screen. if (i == 0 && child.getLeft() > getPaddingLeft()) { bounds.left = getPaddingLeft(); bounds.right = child.getLeft(); drawDivider(canvas, bounds); } } } } /** * Draws a divider in the given bounds. * * @param canvas The canvas to draw to. * @param bounds The bounds of the divider. */ private void drawDivider(Canvas canvas, Rect bounds) { if (mDivider != null) { mDivider.setBounds(bounds); mDivider.draw(canvas); } } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); drawDividers(canvas); } @Override protected void dispatchDraw(Canvas canvas) { super.dispatchDraw(canvas); drawEdgeGlow(canvas); } @Override protected void dispatchSetPressed(boolean pressed) { // Don't dispatch setPressed to our children. We call setPressed on ourselves to // get the selector in the right state, but we don't want to press each child. } protected boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { mFlingTracker.fling(mNextX, 0, (int) -velocityX, 0, 0, mMaxX, 0, 0); setCurrentScrollState(OnScrollStateChangedListener.ScrollState.SCROLL_STATE_FLING); requestLayout(); return true; } protected boolean onDown(MotionEvent e) { // If the user just caught a fling, then disable all touch actions until they release their finger mBlockTouchAction = !mFlingTracker.isFinished(); // Allow a finger down event to catch a fling mFlingTracker.forceFinished(true); setCurrentScrollState(OnScrollStateChangedListener.ScrollState.SCROLL_STATE_IDLE); unpressTouchedChild(); if (!mBlockTouchAction) { // Find the child that was pressed final int index = getChildIndex((int) e.getX(), (int) e.getY()); if (index >= 0) { // Save off view being touched so it can later be released mViewBeingTouched = getChildAt(index); if (mViewBeingTouched != null) { // Set the view as pressed mViewBeingTouched.setPressed(true); refreshDrawableState(); } } } return true; } /** If a view is currently pressed then unpress it */ private void unpressTouchedChild() { if (mViewBeingTouched != null) { // Set the view as not pressed mViewBeingTouched.setPressed(false); refreshDrawableState(); // Null out the view so we don't leak it mViewBeingTouched = null; } } private class GestureListener extends GestureDetector.SimpleOnGestureListener { @Override public boolean onDown(MotionEvent e) { return HorizontalListView.this.onDown(e); } @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { return HorizontalListView.this.onFling(e1, e2, velocityX, velocityY); } @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { // Lock the user into interacting just with this view requestParentListViewToNotInterceptTouchEvents(true); setCurrentScrollState(OnScrollStateChangedListener.ScrollState.SCROLL_STATE_TOUCH_SCROLL); unpressTouchedChild(); mNextX += (int) distanceX; updateOverscrollAnimation(Math.round(distanceX)); requestLayout(); return true; } @Override public boolean onSingleTapConfirmed(MotionEvent e) { unpressTouchedChild(); OnItemClickListener onItemClickListener = getOnItemClickListener(); final int index = getChildIndex((int) e.getX(), (int) e.getY()); // If the tap is inside one of the child views, and we are not blocking touches if (index >= 0 && !mBlockTouchAction) { View child = getChildAt(index); int adapterIndex = mLeftViewAdapterIndex + index; if (onItemClickListener != null) { onItemClickListener.onItemClick(HorizontalListView.this, child, adapterIndex, mAdapter.getItemId(adapterIndex)); return true; } } if (mOnClickListener != null && !mBlockTouchAction) { mOnClickListener.onClick(HorizontalListView.this); } return false; } @Override public void onLongPress(MotionEvent e) { unpressTouchedChild(); final int index = getChildIndex((int) e.getX(), (int) e.getY()); if (index >= 0 && !mBlockTouchAction) { View child = getChildAt(index); OnItemLongClickListener onItemLongClickListener = getOnItemLongClickListener(); if (onItemLongClickListener != null) { int adapterIndex = mLeftViewAdapterIndex + index; boolean handled = onItemLongClickListener.onItemLongClick(HorizontalListView.this, child, adapterIndex, mAdapter.getItemId(adapterIndex)); if (handled) { // BZZZTT!!1! performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); } } } } }; @Override public boolean onTouchEvent(MotionEvent event) { // Detect when the user lifts their finger off the screen after a touch if (event.getAction() == MotionEvent.ACTION_UP) { // If not flinging then we are idle now. The user just finished a finger scroll. if (mFlingTracker == null || mFlingTracker.isFinished()) { setCurrentScrollState(OnScrollStateChangedListener.ScrollState.SCROLL_STATE_IDLE); } // Allow the user to interact with parent views requestParentListViewToNotInterceptTouchEvents(false); releaseEdgeGlow(); } else if (event.getAction() == MotionEvent.ACTION_CANCEL) { unpressTouchedChild(); releaseEdgeGlow(); // Allow the user to interact with parent views requestParentListViewToNotInterceptTouchEvents(false); } return super.onTouchEvent(event); } /** Release the EdgeGlow so it animates */ private void releaseEdgeGlow() { if (mEdgeGlowLeft != null) { mEdgeGlowLeft.onRelease(); } if (mEdgeGlowRight != null) { mEdgeGlowRight.onRelease(); } } /** * Sets a listener to be called when the HorizontalListView has been scrolled to a point where it is * running low on data. An example use case is wanting to auto download more data when the user * has scrolled to the point where only 10 items are left to be rendered off the right of the * screen. To get called back at that point just register with this function with a * numberOfItemsLeftConsideredLow value of 10. <br> * <br> * This will only be called once to notify that the HorizontalListView is running low on data. * Calling notifyDataSetChanged on the adapter will allow this to be called again once low on data. * * @param listener The listener to be notified when the number of array adapters items left to * be shown is running low. * * @param numberOfItemsLeftConsideredLow The number of array adapter items that have not yet * been displayed that is considered too low. */ public void setRunningOutOfDataListener(RunningOutOfDataListener listener, int numberOfItemsLeftConsideredLow) { mRunningOutOfDataListener = listener; mRunningOutOfDataThreshold = numberOfItemsLeftConsideredLow; } /** * This listener is used to allow notification when the HorizontalListView is running low on data to display. */ public static interface RunningOutOfDataListener { /** Called when the HorizontalListView is running out of data and has reached at least the provided threshold. */ void onRunningOutOfData(); } /** * Determines if we are low on data and if so will call to notify the listener, if there is one, * that we are running low on data. */ private void determineIfLowOnData() { // Check if the threshold has been reached and a listener is registered if (mRunningOutOfDataListener != null && mAdapter != null && mAdapter.getCount() - (mRightViewAdapterIndex + 1) < mRunningOutOfDataThreshold) { // Prevent notification more than once if (!mHasNotifiedRunningLowOnData) { mHasNotifiedRunningLowOnData = true; mRunningOutOfDataListener.onRunningOutOfData(); } } } /** * Register a callback to be invoked when the HorizontalListView has been clicked. * * @param listener The callback that will be invoked. */ @Override public void setOnClickListener(OnClickListener listener) { mOnClickListener = listener; } /** * Interface definition for a callback to be invoked when the view scroll state has changed. */ public interface OnScrollStateChangedListener { public enum ScrollState { /** * The view is not scrolling. Note navigating the list using the trackball counts as being * in the idle state since these transitions are not animated. */ SCROLL_STATE_IDLE, /** * The user is scrolling using touch, and their finger is still on the screen */ SCROLL_STATE_TOUCH_SCROLL, /** * The user had previously been scrolling using touch and had performed a fling. The * animation is now coasting to a stop */ SCROLL_STATE_FLING } /** * Callback method to be invoked when the scroll state changes. * * @param scrollState The current scroll state. */ public void onScrollStateChanged(ScrollState scrollState); } /** * Sets a listener to be invoked when the scroll state has changed. * * @param listener The listener to be invoked. */ public void setOnScrollStateChangedListener(OnScrollStateChangedListener listener) { mOnScrollStateChangedListener = listener; } /** * Call to set the new scroll state. * If it has changed and a listener is registered then it will be notified. */ private void setCurrentScrollState(OnScrollStateChangedListener.ScrollState newScrollState) { // If the state actually changed then notify listener if there is one if (mCurrentScrollState != newScrollState && mOnScrollStateChangedListener != null) { mOnScrollStateChangedListener.onScrollStateChanged(newScrollState); } mCurrentScrollState = newScrollState; } /** * Updates the over scroll animation based on the scrolled offset. * * @param scrolledOffset The scroll offset */ private void updateOverscrollAnimation(final int scrolledOffset) { if (mEdgeGlowLeft == null || mEdgeGlowRight == null) return; // Calculate where the next scroll position would be int nextScrollPosition = mCurrentX + scrolledOffset; // If not currently in a fling (Don't want to allow fling offset updates to cause over scroll animation) if (mFlingTracker == null || mFlingTracker.isFinished()) { // If currently scrolled off the left side of the list and the adapter is not empty if (nextScrollPosition < 0) { // Calculate the amount we have scrolled since last frame int overscroll = Math.abs(scrolledOffset); // Tell the edge glow to redraw itself at the new offset mEdgeGlowLeft.onPull((float) overscroll / getRenderWidth()); // Cancel animating right glow if (!mEdgeGlowRight.isFinished()) { mEdgeGlowRight.onRelease(); } } else if (nextScrollPosition > mMaxX) { // Scrolled off the right of the list // Calculate the amount we have scrolled since last frame int overscroll = Math.abs(scrolledOffset); // Tell the edge glow to redraw itself at the new offset mEdgeGlowRight.onPull((float) overscroll / getRenderWidth()); // Cancel animating left glow if (!mEdgeGlowLeft.isFinished()) { mEdgeGlowLeft.onRelease(); } } } } /** * Checks if the edge glow should be used enabled. * The glow is not enabled unless there are more views than can fit on the screen at one time. */ private boolean isEdgeGlowEnabled() { if (mAdapter == null || mAdapter.isEmpty()) return false; // If the maxx is more then zero then the user can scroll, so the edge effects should be shown return mMaxX > 0; } @TargetApi(11) /** Wrapper class to protect access to API version 11 and above features */ private static final class HoneycombPlus { static { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) { throw new RuntimeException("Should not get to HoneycombPlus class unless sdk is >= 11!"); } } /** Sets the friction for the provided scroller */ public static void setFriction(Scroller scroller, float friction) { if (scroller != null) { scroller.setFriction(friction); } } } @TargetApi(14) /** Wrapper class to protect access to API version 14 and above features */ private static final class IceCreamSandwichPlus { static { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) { throw new RuntimeException("Should not get to IceCreamSandwichPlus class unless sdk is >= 14!"); } } /** Gets the velocity for the provided scroller */ public static float getCurrVelocity(Scroller scroller) { return scroller.getCurrVelocity(); } } }