package com.jayway.columnlist; import android.content.Context; import android.content.res.TypedArray; import android.database.DataSetObserver; import android.os.Build; import android.util.AttributeSet; import android.util.FloatMath; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.animation.AnimationUtils; import android.widget.AdapterView; import android.widget.ListAdapter; import java.util.ArrayList; import java.util.HashMap; /** * A multi column list */ public class ColumnListView extends AdapterView<ListAdapter> { // Touch states private enum TouchState { RESTING, PRESSED, SCROLLING, LONG_PRESS } // Holder class for the data of items of the list // All the data is provided by the adapter private static class Item { // Position in the adapter for this item private int mPosition; // The view of the item private View mView; // The id for this item private long mId; } // Represents the visible part of a column private static class Column { // The items that makes up the column final ArrayList<Item> mItems = new ArrayList<Item>(); // The left position of the column int mLeft; // The top coordinate of the column int mTop; // The bottom coordinate of the column int mBottom; // The list of item positions that used to be above the current // list of items final ArrayList<Integer> mPreviousItems = new ArrayList<Integer>(); } // The adapter that contains the data private ListAdapter mAdapter; // The list of columns final private ArrayList<Column> mColumns = new ArrayList<Column>(); // An observer that is registered on the adapter to be able to react to changes in the data private DataSetObserver mDataSetObserver; // Cache of item views final private HashMap<Integer, ArrayList<View>> mItemViewCache = new HashMap<Integer, ArrayList<View>>(); // The padding between columns private int mPadding; // The width of a column private int mColumnWidth; // The current touch status private TouchState mTouchState = TouchState.RESTING; // The length needed to move a touch for it to be a scroll private final int mTouchSlop; // The x-coordinate where the touch started private int mTouchDownX; // The y-coordinate where the touch started private int mTouchDownY; // The first column top position when the touch started private int mListTopAtTouchStart; // A velocity tracker used to calculate the velocity of the fling private VelocityTracker mVelocityTracker; // The touched item, if any private Item mTouchedItem; // The number of columns private int mNumberOfColumns; // The damping while flinging, higher number -> more damping private float mFlingDamping; // The spring when snapping, higher number -> faster snap private float mSnapSpring; // The damping when snapping, calculated based on the snap spring private float mSnapDamping; // The amount of resistance when dragging outside of limits private float mRubberbandFactor; // True if the views should be reloaded next layout pass private boolean mReloadViews; // True if overscoll is allowed private boolean mOverscroll; public ColumnListView(final Context context, final AttributeSet attrs) { super(context, attrs); readAttrs(context, attrs); mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); createColumns(mNumberOfColumns); } private void readAttrs(final Context context, final AttributeSet attrs) { TypedArray attributes = context.obtainStyledAttributes(attrs, R.styleable.ColumnListView); mNumberOfColumns = attributes.getInt(R.styleable.ColumnListView_columns, 2); mPadding = (int) attributes.getDimension(R.styleable.ColumnListView_column_padding, 0); mOverscroll = attributes.getBoolean(R.styleable.ColumnListView_overscroll, true); mFlingDamping = attributes.getFloat(R.styleable.ColumnListView_fling_damping, 1.5f); mSnapSpring = attributes.getInt(R.styleable.ColumnListView_snap_spring, 100); mSnapDamping = 2 * FloatMath.sqrt(mSnapSpring); mRubberbandFactor = attributes.getFloat(R.styleable.ColumnListView_rubberband_factor, 0.4f); attributes.recycle(); } private void createColumns(int numberOfColumns) { for (int i = 0; i < numberOfColumns; i++) { Column column = new Column(); mColumns.add(column); } } @Override public ListAdapter getAdapter() { return mAdapter; } @Override public void setAdapter(final ListAdapter adapter) { if (mAdapter != null) { // if we had an adapter before, unregister the DataSetObserver on it mAdapter.unregisterDataSetObserver(mDataSetObserver); } clearAllData(); mAdapter = adapter; if (mAdapter != null) { ensureDataSetObserverIsCreated(); mAdapter.registerDataSetObserver(mDataSetObserver); } requestLayout(); } private void ensureDataSetObserverIsCreated() { if (mDataSetObserver == null) { mDataSetObserver = new DataSetObserver() { @Override public void onChanged() { mReloadViews = true; requestLayout(); } @Override public void onInvalidated() { clearAllData(); requestLayout(); } }; } } private void clearAllData() { clearAllViews(); for (Column column : mColumns) { column.mItems.clear(); column.mTop = 0; column.mBottom = 0; column.mPreviousItems.clear(); } mItemViewCache.clear(); } private void clearAllViews() { for (Column column : mColumns) { for (Item item : column.mItems) { removeItemView(item); } } removeAllViewsInLayout(); } @Override public View getSelectedView() { return null; } @Override public void setSelection(final int position) { } @Override protected void onSizeChanged(final int w, final int h, final int oldw, final int oldh) { super.onSizeChanged(w, h, oldw, oldh); updateColumnDimensions(w); } private void updateColumnDimensions(int width) { width -= getPaddingLeft() + getPaddingRight(); mColumnWidth = (width - (mColumns.size() + 1) * mPadding) / mColumns.size(); int columnLeft = mPadding; for (Column column : mColumns) { column.mLeft = columnLeft + getPaddingLeft(); columnLeft += mColumnWidth + mPadding; } } @Override protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); updateColumnDimensions(getMeasuredWidth()); for (Column column : mColumns) { for (Item item : column.mItems) { if (item.mView != null) { measureView(item.mView); } } } } @Override protected void onLayout(final boolean changed, final int left, final int top, final int right, final int bottom) { if (mAdapter == null) { return; } if (mReloadViews) { mReloadViews = false; reloadViews(); } fillList(); } private void reloadViews() { for (Column column : mColumns) { int top = column.mTop + mPadding; for (Item item : column.mItems) { // remove the old view removeItemView(item); // load the new one item.mView = getView(item.mPosition); // add, measure and layout the new view addViewToLayout(item.mView); measureView(item.mView); layoutItem(column, item, top); top += item.mView.getHeight() + mPadding; } } } private void fillList() { fillListDown(); fillListUp(); } private void fillListDown() { int nextPosition = getLastVisiblePosition() + 1; Column column = getNextColumnDown(); while (column != null && nextPosition < mAdapter.getCount()) { Item item = getItemFromAdapter(nextPosition); addItemToColumnDown(column, item); column = getNextColumnDown(); nextPosition++; } } private void fillListUp() { Column column = getNextColumnUp(); int nextPosition = -1; if (column != null && !column.mPreviousItems.isEmpty()) { nextPosition = column.mPreviousItems.remove(0); } while (column != null && nextPosition >= 0) { Item item = getItemFromAdapter(nextPosition); addItemToColumnUp(column, item); column = getNextColumnUp(); if (column != null && !column.mPreviousItems.isEmpty()) { nextPosition = column.mPreviousItems.remove(0); } else { nextPosition = -1; } } } private Column getNextColumnDown() { Column nextColumn = null; int highestBottom = getHeight(); for (Column column : mColumns) { if (column.mBottom < highestBottom) { highestBottom = column.mBottom; nextColumn = column; } } return nextColumn; } private Column getNextColumnUp() { Column nextColumn = null; int lowestTop = 0; for (Column column : mColumns) { if (column.mTop > lowestTop) { lowestTop = column.mBottom; nextColumn = column; } } return nextColumn; } @Override public int getLastVisiblePosition() { int lastPosition = -1; for (Column column : mColumns) { if (!column.mItems.isEmpty()) { int lastPositionInColumn = column.mItems.get(column.mItems.size() - 1).mPosition; if (lastPositionInColumn > lastPosition) { lastPosition = lastPositionInColumn; } } } return lastPosition; } @Override public int getFirstVisiblePosition() { int firstPosition = Integer.MAX_VALUE; for (Column column : mColumns) { int firstPositionInColumn = column.mItems.get(0).mPosition; if (firstPositionInColumn < firstPosition) { firstPosition = firstPositionInColumn; } } return firstPosition; } private Item getItemFromAdapter(final int position) { Item item = new Item(); item.mView = getView(position); item.mPosition = position; item.mId = mAdapter.getItemId(position); return item; } private View getView(final int position) { int viewType = mAdapter.getItemViewType(position); View cachedView = getViewFromCache(viewType); return mAdapter.getView(position, cachedView, this); } private void addItemToColumnDown(final Column column, final Item item) { addViewToLayout(item.mView); measureView(item.mView); int height = item.mView.getMeasuredHeight(); column.mItems.add(item); int top = column.mBottom + mPadding; layoutItem(column, item, top); column.mBottom += height + mPadding; } private void addItemToColumnUp(final Column column, final Item item) { addViewToLayout(item.mView); measureView(item.mView); int height = item.mView.getMeasuredHeight(); column.mItems.add(0, item); column.mTop -= height + mPadding; if (column == mColumns.get(0)) { mListTopAtTouchStart -= height + mPadding; } int top = column.mTop + mPadding; layoutItem(column, item, top); } private void layoutItem(final Column column, final Item item, final int top) { item.mView.layout(column.mLeft, top, column.mLeft + mColumnWidth, top + item.mView.getMeasuredHeight()); } private void addViewToLayout(final View view) { LayoutParams params = view.getLayoutParams(); if (params == null) { params = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); } addViewInLayout(view, -1, params, true); } private void measureView(final View view) { ViewGroup.LayoutParams params = view.getLayoutParams(); int width = mColumnWidth; int widthMeasureSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY); int height = params.height; int heightMeasureSpec; if (height > 0) { heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY); } else { heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); } view.measure(widthMeasureSpec, heightMeasureSpec); } @Override public boolean onTouchEvent(final MotionEvent event) { if (event.getActionIndex() > 0) { return false; } boolean handled; switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: handled = startTouch(event); break; case MotionEvent.ACTION_MOVE: handled = handleTouchMove(event); break; case MotionEvent.ACTION_UP: handled = handleTouchUp(event); break; case MotionEvent.ACTION_POINTER_UP: case MotionEvent.ACTION_POINTER_DOWN: handled = false; break; default: handled = endTouch(); break; } return handled; } private boolean startTouch(MotionEvent event) { mTouchState = TouchState.PRESSED; mTouchDownX = (int) event.getX(); mTouchDownY = (int) event.getY(); mListTopAtTouchStart = getListTop(); mVelocityTracker = VelocityTracker.obtain(); mVelocityTracker.addMovement(event); mTouchedItem = getTouchedItem((int) event.getX(), (int) event.getY()); // post a runnable that will set the touched view to pressed // it's done after a while since this might still be a scroll postDelayed(mSetPressedRunnable, ViewConfiguration.getScrollDefaultDelay()); // post a runnable that will set call the onItemLongClickListener for the // touched item and put us in LONGPRESS mode postDelayed(mLongPressRunnable, ViewConfiguration.getLongPressTimeout()); return true; } // Runnable that sets pressed state to the touched item final private Runnable mSetPressedRunnable = new Runnable() { @Override public void run() { if (mTouchedItem != null && mTouchedItem.mView != null) { mTouchedItem.mView.setPressed(true); } } }; private Runnable mLongPressRunnable = new Runnable() { @Override public void run() { OnItemLongClickListener onItemLongClickListener = getOnItemLongClickListener(); if (onItemLongClickListener != null) { boolean longPressConsumed = onItemLongClickListener.onItemLongClick(ColumnListView.this, mTouchedItem.mView, mTouchedItem.mPosition, mTouchedItem.mId); if (longPressConsumed) { mTouchState = TouchState.LONG_PRESS; } } } }; private Item getTouchedItem(int x, int y) { for (Column column : mColumns) { if (x > column.mLeft && x < column.mLeft + mColumnWidth) { for (Item item : column.mItems) { View view = item.mView; if (view.getTop() < y && view.getBottom() > y) { return item; } } } } return null; } private boolean handleTouchMove(MotionEvent event) { mVelocityTracker.addMovement(event); if (mTouchState == TouchState.PRESSED && hasMovedFarEnoughForScroll(event)) { startScrolling(event); } else if (mTouchState == TouchState.SCROLLING) { handleTouchScroll(event); } return true; } private boolean hasMovedFarEnoughForScroll(MotionEvent event) { int x = (int) event.getX(); int y = (int) event.getY(); if ((mTouchDownX - mTouchSlop < x && x < mTouchDownX + mTouchSlop) && (mTouchDownY - mTouchSlop < y && y < mTouchDownY + mTouchSlop)) { return false; } return true; } private void startScrolling(MotionEvent event) { removeCallbacks(mSetPressedRunnable); removeCallbacks(mLongPressRunnable); if (mTouchedItem != null && mTouchedItem.mView != null) { mTouchedItem.mView.setPressed(false); } mTouchDownX = (int) event.getX(); mTouchDownY = (int) event.getY(); mTouchState = TouchState.SCROLLING; } private void handleTouchScroll(MotionEvent event) { int listTop = (int) (mListTopAtTouchStart + (event.getY() - mTouchDownY)); scrollListTo(applyRubberBand(listTop)); } private int applyRubberBand(final int pos) { float rubberbandFactor = mOverscroll ? mRubberbandFactor : 0; if (isFirstItemShowing()) { int topRubberbandPos = getTopSnapPos(); if (pos > topRubberbandPos) { return (int) (topRubberbandPos + (pos - topRubberbandPos) * rubberbandFactor); } } if (isLastItemShowing()) { int bottomRubberbandPos = getBottomSnapPos(); if (pos < bottomRubberbandPos) { return (int) (bottomRubberbandPos + (pos - bottomRubberbandPos) * rubberbandFactor); } } return pos; } private void scrollListTo(final int listTop) { offsetListTo(listTop); removeNonVisibleViews(); fillList(); invalidate(); } private void offsetListTo(int pos) { int delta = pos - mColumns.get(0).mTop; for (Column column : mColumns) { column.mTop += delta; column.mBottom += delta; for (Item item : column.mItems) { item.mView.offsetTopAndBottom(delta); } } } private void removeNonVisibleViews() { for (Column column : mColumns) { while (!isTopItemVisible(column) && !isLastItemShowing() && column.mItems.size() > 1) { removeTopItem(column); } while (!isBottomItemVisible(column) && !isFirstItemShowing() && column.mItems.size() > 1) { removeBottomItem(column); } } } private boolean isTopItemVisible(final Column column) { return column.mItems.get(0).mView.getBottom() >= 0; } private boolean isBottomItemVisible(final Column column) { return column.mItems.get(column.mItems.size() - 1).mView.getTop() <= getHeight() - getPaddingBottom(); } private void removeTopItem(final Column column) { Item item = column.mItems.remove(0); column.mTop += item.mView.getHeight() + mPadding; if (column == mColumns.get(0)) { mListTopAtTouchStart += item.mView.getHeight() + mPadding; } column.mPreviousItems.add(0, item.mPosition); removeItemView(item); } private void removeBottomItem(final Column column) { Item item = column.mItems.remove(column.mItems.size() - 1); column.mBottom -= item.mView.getHeight() + mPadding; removeItemView(item); } private void removeItemView(final Item item) { removeViewInLayout(item.mView); addItemViewToCache(item); item.mView = null; } private boolean isLastItemShowing() { for (Column column : mColumns) { if (column.mItems.get(column.mItems.size() - 1).mPosition == mAdapter.getCount() - 1) { return true; } } return false; } private boolean isFirstItemShowing() { for (Column column : mColumns) { if (column.mItems.get(0).mPosition == 0) { return true; } } return false; } private boolean handleTouchUp(MotionEvent event) { if (mTouchState == TouchState.PRESSED && mTouchedItem != null) { handleItemClick(mTouchedItem); } endTouch(); return true; } private void handleItemClick(Item item) { OnItemClickListener onItemClickListener = getOnItemClickListener(); if (onItemClickListener != null) { onItemClickListener.onItemClick(this, item.mView, item.mPosition, item.mId); } // remove any runnable that will set pressed state removeCallbacks(mSetPressedRunnable); if (item != null && item.mView != null) { if (item.mView.isPressed()) { // if it was already in pressed state, set it to not pressed item.mView.setPressed(false); } else { // if it was not in pressed state, set it to pressed and // post a runnable that resets it after a short duration // this way a click is always visible to the user item.mView.setPressed(true); final View view = item.mView; postDelayed(new Runnable() { @Override public void run() { view.setPressed(false); } }, ViewConfiguration.getPressedStateDuration()); } } } private boolean endTouch() { removeCallbacks(mLongPressRunnable); removeCallbacks(mSetPressedRunnable); mVelocityTracker.computeCurrentVelocity(1000); float velocity = mVelocityTracker.getYVelocity(); if (mTouchState == TouchState.LONG_PRESS) { velocity = 0; } new FlingRunnable(velocity).start(); mVelocityTracker.recycle(); mVelocityTracker = null; mTouchState = TouchState.RESTING; return true; } private void addItemViewToCache(final Item item) { int itemViewType = mAdapter.getItemViewType(item.mPosition); ArrayList<View> viewCacheForType = mItemViewCache.get(itemViewType); if (viewCacheForType == null) { viewCacheForType = new ArrayList<View>(); mItemViewCache.put(itemViewType, viewCacheForType); } viewCacheForType.add(item.mView); } private View getViewFromCache(final int itemViewType) { ArrayList<View> viewCacheForType = mItemViewCache.get(itemViewType); if (viewCacheForType != null && !viewCacheForType.isEmpty()) { return viewCacheForType.remove(0); } return null; } private int getTopSnapPos() { return getPaddingTop(); } private int getBottomSnapPos() { int listHeight = getListHeight(); if (listHeight < getHeight() - getPaddingBottom()) { return mPadding; } return getHeight() - getPaddingBottom() - getListHeight(); } private int getListHeight() { int listTop = getListTop(); int listHeight = 0; for (Column column : mColumns) { int columnHeight = column.mBottom - listTop; if (columnHeight > listHeight) { listHeight = columnHeight; } } return listHeight + mPadding; } private int getListTop() { return mColumns.get(0).mTop; } private class FlingRunnable implements Runnable { // The minimum speed of a fling move to start a fling scroll public static final int SPEED_THRESHOLD = 200; // The maximum time between frames in milliseconds public static final int MAX_FRAME_DELAY = 50; // The wanted time between frames in milliseconds public static final int WANTED_FRAME_DELAY = 10; // The minimum amount of acceleration to keep flinging public static final int ACCELERATION_THERSHOLD = 20; // The current velocity of the fling private float mVelocity; // The last time the fling was updated private long mLastTime; // The point to snap the top of the list to private int mSnapPoint; // True if we should snap the top of the list to the snap point private boolean mSnapping; public FlingRunnable(float velocity) { if (Math.abs(velocity) > SPEED_THRESHOLD) { mVelocity = velocity; } else { mVelocity = 0; } mLastTime = AnimationUtils.currentAnimationTimeMillis(); } public void start() { removeCallbacks(this); scheduleNewFrame(); } @Override public void run() { if (mTouchState != TouchState.RESTING) { // If the user is touching the list, then we just abort return; } int listTop = getListTop(); if (!mSnapping) { snapIfNeeded(listTop); } float acceleration = getAcceleration(); float dt = getDeltaTAndSaveCurrentTime(); mVelocity += acceleration * dt; int deltaPos = (int) (mVelocity * dt); // If we are snapping and we're not at the snap point, then we (also) decrease the // distance to the snap point by one pixel to make sure we reach the snap point if (mSnapping && getListTop() + deltaPos != mSnapPoint) { if (getListTop() + deltaPos < mSnapPoint) { deltaPos++; } else { deltaPos--; } } if (!mOverscroll) { // Overscroll is disabled, check if the new position makes us want to snap if (!mSnapping) { snapIfNeeded(listTop + deltaPos); } // if we should snap, reset acceleration and velocity and re-calculate the // delta pos so that we position the list exactly at the snap position if (mSnapping) { mVelocity = 0; acceleration = 0; deltaPos = mSnapPoint - listTop; } } scrollListTo(listTop + deltaPos); if (Math.abs(acceleration) > ACCELERATION_THERSHOLD) { scheduleNewFrame(); } } private float getAcceleration() { // the damping part of the acceleration (directed against the velocity) float acceleration = (mSnapping ? mSnapDamping : mFlingDamping) * -mVelocity; if (mSnapping) { int distanceToSnapPoint = mSnapPoint - getListTop(); // the spring part of the acceleration (directed towards the snap point) acceleration += mSnapSpring * distanceToSnapPoint; } return acceleration; } private void snapIfNeeded(int listTop) { if (isFirstItemShowing()) { mSnapPoint = getTopSnapPos(); if (listTop > mSnapPoint && mVelocity >= 0) { // the top row is the first row and... // the top of the list is farther down than the snap pos and... // the velocity is directed downward mSnapping = true; } } if (isLastItemShowing()) { mSnapPoint = getBottomSnapPos(); if (listTop < mSnapPoint && mVelocity <= 0) { // the bottom row is the last row and ... // the top of the list is higher up than the snap pos and... // the velocity is directed upwards mSnapping = true; } } } private float getDeltaTAndSaveCurrentTime() { long now = AnimationUtils.currentAnimationTimeMillis(); long deltaT = now - mLastTime; if (deltaT > MAX_FRAME_DELAY) { deltaT = MAX_FRAME_DELAY; } mLastTime = now; return deltaT / 1000f; } private void scheduleNewFrame() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { postOnAnimationDelayed(this, WANTED_FRAME_DELAY); } else { postDelayed(this, WANTED_FRAME_DELAY); } } } }