/* * Copyright (c) 2013 Etsy * Copyright (C) 2006 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.smartandroid.sa.stagger; import android.content.Context; import android.database.DataSetObserver; import android.graphics.Rect; import android.os.Handler; import android.os.Parcel; import android.os.Parcelable; import android.support.v4.util.SparseArrayCompat; import android.support.v4.view.MotionEventCompat; import android.support.v4.view.ViewCompat; import android.util.AttributeSet; import android.util.Log; import android.view.*; import android.widget.AbsListView; import android.widget.AdapterView; import android.widget.ListAdapter; import android.widget.Scroller; import java.util.ArrayList; /** * An extendable implementation of the Android {@link android.widget.ListView} * <p/> * This is partly inspired by the incomplete StaggeredGridView supplied in the * Android 4.2+ source & the {@link android.widget.AbsListView} & {@link android.widget.ListView} source; * however this is intended to have a smaller simplified * scope of functionality and hopefully therefore be a workable solution. * <p/> * Some things that this doesn't support (yet) * - Dividers (We don't use them in our Etsy grid) * - Edge effect * - Fading edge - yuck * - Item selection * - Focus * <p/> * Note: we only really extend {@link android.widget.AbsListView} so we can appear to be one of its direct subclasses. * However most of the code we need to modify is either 1. hidden or 2. package private * So a lot of it's code and some {@link android.widget.AdapterView} code is repeated here * Be careful with this - not everything may be how you expect if you assume this to be * a regular old {@link android.widget.ListView} */ public abstract class ExtendableListView extends AbsListView { private static final String TAG = "ExtendableListView"; private static final boolean DBG = false; private static final int TOUCH_MODE_IDLE = 0; private static final int TOUCH_MODE_SCROLLING = 1; private static final int TOUCH_MODE_FLINGING = 2; private static final int TOUCH_MODE_DOWN = 3; private static final int TOUCH_MODE_TAP = 4; private static final int TOUCH_MODE_DONE_WAITING = 5; private static final int INVALID_POINTER = -1; // Layout using our default existing state private static final int LAYOUT_NORMAL = 0; // Layout from the first item down private static final int LAYOUT_FORCE_TOP = 1; // Layout from the saved instance state data private static final int LAYOUT_SYNC = 2; private int mLayoutMode; private int mTouchMode; private int mScrollState = OnScrollListener.SCROLL_STATE_IDLE; // Rectangle used for hit testing children // private Rect mTouchFrame; // TODO : ItemClick support from AdapterView // For managing scrolling private VelocityTracker mVelocityTracker = null; private int mTouchSlop; private int mMaximumVelocity; private int mFlingVelocity; // TODO : Edge effect handling // private EdgeEffectCompat mEdgeGlowTop; // private EdgeEffectCompat mEdgeGlowBottom; // blocker for when we're in a layout pass private boolean mInLayout; ListAdapter mAdapter; private int mMotionY; private int mMotionX; private int mMotionCorrection; private int mMotionPosition; private int mLastY; private int mActivePointerId = INVALID_POINTER; protected int mFirstPosition; // are we attached to a window - we shouldn't handle any touch events if we're not! private boolean mIsAttached; /** * When set to true, calls to requestLayout() will not propagate up the parent hierarchy. * This is used to layout the children during a layout pass. */ private boolean mBlockLayoutRequests = false; // has our data changed - and should we react to it private boolean mDataChanged; private int mItemCount; private int mOldItemCount; final boolean[] mIsScrap = new boolean[1]; private RecycleBin mRecycleBin; private AdapterDataSetObserver mObserver; private int mWidthMeasureSpec; private FlingRunnable mFlingRunnable; protected boolean mClipToPadding; private PerformClick mPerformClick; private Runnable mPendingCheckForTap; private CheckForLongPress mPendingCheckForLongPress; private class CheckForLongPress extends WindowRunnnable implements Runnable { public void run() { final int motionPosition = mMotionPosition; final View child = getChildAt(motionPosition); if (child != null) { final int longPressPosition = mMotionPosition; final long longPressId = mAdapter.getItemId(mMotionPosition + mFirstPosition); boolean handled = false; if (sameWindow() && !mDataChanged) { handled = performLongPress(child, longPressPosition + mFirstPosition, longPressId); } if (handled) { mTouchMode = TOUCH_MODE_IDLE; setPressed(false); child.setPressed(false); } else { mTouchMode = TOUCH_MODE_DONE_WAITING; } } } } /** * A class that represents a fixed view in a list, for example a header at the top * or a footer at the bottom. */ public class FixedViewInfo { /** * The view to add to the list */ public View view; /** * The data backing the view. This is returned from {@link android.widget.ListAdapter#getItem(int)}. */ public Object data; /** * <code>true</code> if the fixed view should be selectable in the list */ public boolean isSelectable; } private ArrayList<FixedViewInfo> mHeaderViewInfos; private ArrayList<FixedViewInfo> mFooterViewInfos; public ExtendableListView(final Context context, final AttributeSet attrs, final int defStyle) { super(context, attrs, defStyle); // setting up to be a scrollable view group setWillNotDraw(false); setClipToPadding(false); setFocusableInTouchMode(false); final ViewConfiguration viewConfiguration = ViewConfiguration.get(context); mTouchSlop = viewConfiguration.getScaledTouchSlop(); mMaximumVelocity = viewConfiguration.getScaledMaximumFlingVelocity(); mFlingVelocity = viewConfiguration.getScaledMinimumFlingVelocity(); mRecycleBin = new RecycleBin(); mObserver = new AdapterDataSetObserver(); mHeaderViewInfos = new ArrayList<FixedViewInfo>(); mFooterViewInfos = new ArrayList<FixedViewInfo>(); // start our layout mode drawing from the top mLayoutMode = LAYOUT_NORMAL; } // ////////////////////////////////////////////////////////////////////////////////////////// // MAINTAINING SOME STATE // @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); if (mAdapter != null) { // Data may have changed while we were detached. Refresh. mDataChanged = true; mOldItemCount = mItemCount; mItemCount = mAdapter.getCount(); } mIsAttached = true; } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); // Detach any view left in the scrap heap mRecycleBin.clear(); if (mFlingRunnable != null) { removeCallbacks(mFlingRunnable); } mIsAttached = false; } @Override protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) { // TODO : handle focus and its impact on selection - if we add item selection support } @Override public void onWindowFocusChanged(boolean hasWindowFocus) { // TODO : handle focus and its impact on selection - if we add item selection support } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { onSizeChanged(w, h); } protected void onSizeChanged(int w, int h) { if (getChildCount() > 0) { stopFlingRunnable(); mRecycleBin.clear(); mDataChanged = true; rememberSyncState(); } } // ////////////////////////////////////////////////////////////////////////////////////////// // ADAPTER // @Override public ListAdapter getAdapter() { return mAdapter; } @Override public void setAdapter(final ListAdapter adapter) { if (mAdapter != null) { mAdapter.unregisterDataSetObserver(mObserver); } // use a wrapper list adapter if we have a header or footer if (mHeaderViewInfos.size() > 0 || mFooterViewInfos.size() > 0) { mAdapter = new HeaderViewListAdapter(mHeaderViewInfos, mFooterViewInfos, adapter); } else { mAdapter = adapter; } mDataChanged = true; mItemCount = mAdapter != null ? mAdapter.getCount() : 0; if (mAdapter != null) { mAdapter.registerDataSetObserver(mObserver); mRecycleBin.setViewTypeCount(mAdapter.getViewTypeCount()); } requestLayout(); } @Override public int getCount() { return mItemCount; } // ////////////////////////////////////////////////////////////////////////////////////////// // ADAPTER VIEW - UNSUPPORTED // @Override public View getSelectedView() { if (DBG) Log.e(TAG, "getSelectedView() is not supported in ExtendableListView yet"); return null; } @Override public void setSelection(final int position) { if (position >= 0) { mLayoutMode = LAYOUT_SYNC; mSpecificTop = getListPaddingTop(); mFirstPosition = 0; if (mNeedSync) { mSyncPosition = position; mSyncRowId = mAdapter.getItemId(position); } requestLayout(); } } // ////////////////////////////////////////////////////////////////////////////////////////// // HEADER & FOOTER // /** * Add a fixed view to appear at the top of the list. If addHeaderView is * called more than once, the views will appear in the order they were * added. Views added using this call can take focus if they want. * <p/> * NOTE: Call this before calling setAdapter. This is so ListView can wrap * the supplied cursor with one that will also account for header and footer * views. * * @param v The view to add. * @param data Data to associate with this view * @param isSelectable whether the item is selectable */ public void addHeaderView(View v, Object data, boolean isSelectable) { if (mAdapter != null && !(mAdapter instanceof HeaderViewListAdapter)) { throw new IllegalStateException( "Cannot add header view to list -- setAdapter has already been called."); } FixedViewInfo info = new FixedViewInfo(); info.view = v; info.data = data; info.isSelectable = isSelectable; mHeaderViewInfos.add(info); // in the case of re-adding a header view, or adding one later on, // we need to notify the observer if (mAdapter != null && mObserver != null) { mObserver.onChanged(); } } /** * Add a fixed view to appear at the top of the list. If addHeaderView is * called more than once, the views will appear in the order they were * added. Views added using this call can take focus if they want. * <p/> * NOTE: Call this before calling setAdapter. This is so ListView can wrap * the supplied cursor with one that will also account for header and footer * views. * * @param v The view to add. */ public void addHeaderView(View v) { addHeaderView(v, null, true); } public int getHeaderViewsCount() { return mHeaderViewInfos.size(); } /** * Removes a previously-added header view. * * @param v The view to remove * @return true if the view was removed, false if the view was not a header * view */ public boolean removeHeaderView(View v) { if (mHeaderViewInfos.size() > 0) { boolean result = false; if (mAdapter != null && ((HeaderViewListAdapter) mAdapter).removeHeader(v)) { if (mObserver != null) { mObserver.onChanged(); } result = true; } removeFixedViewInfo(v, mHeaderViewInfos); return result; } return false; } private void removeFixedViewInfo(View v, ArrayList<FixedViewInfo> where) { int len = where.size(); for (int i = 0; i < len; ++i) { FixedViewInfo info = where.get(i); if (info.view == v) { where.remove(i); break; } } } /** * Add a fixed view to appear at the bottom of the list. If addFooterView is * called more than once, the views will appear in the order they were * added. Views added using this call can take focus if they want. * <p/> * NOTE: Call this before calling setAdapter. This is so ListView can wrap * the supplied cursor with one that will also account for header and footer * views. * * @param v The view to add. * @param data Data to associate with this view * @param isSelectable true if the footer view can be selected */ public void addFooterView(View v, Object data, boolean isSelectable) { // NOTE: do not enforce the adapter being null here, since unlike in // addHeaderView, it was never enforced here, and so existing apps are // relying on being able to add a footer and then calling setAdapter to // force creation of the HeaderViewListAdapter wrapper FixedViewInfo info = new FixedViewInfo(); info.view = v; info.data = data; info.isSelectable = isSelectable; mFooterViewInfos.add(info); // in the case of re-adding a footer view, or adding one later on, // we need to notify the observer if (mAdapter != null && mObserver != null) { mObserver.onChanged(); } } /** * Add a fixed view to appear at the bottom of the list. If addFooterView is called more * than once, the views will appear in the order they were added. Views added using * this call can take focus if they want. * <p>NOTE: Call this before calling setAdapter. This is so ListView can wrap the supplied * cursor with one that will also account for header and footer views. * * @param v The view to add. */ public void addFooterView(View v) { addFooterView(v, null, true); } public int getFooterViewsCount() { return mFooterViewInfos.size(); } /** * Removes a previously-added footer view. * * @param v The view to remove * @return true if the view was removed, false if the view was not a footer view */ public boolean removeFooterView(View v) { if (mFooterViewInfos.size() > 0) { boolean result = false; if (mAdapter != null && ((HeaderViewListAdapter) mAdapter).removeFooter(v)) { if (mObserver != null) { mObserver.onChanged(); } result = true; } removeFixedViewInfo(v, mFooterViewInfos); return result; } return false; } // ////////////////////////////////////////////////////////////////////////////////////////// // Property Overrides // @Override public void setClipToPadding(final boolean clipToPadding) { super.setClipToPadding(clipToPadding); mClipToPadding = clipToPadding; } // ////////////////////////////////////////////////////////////////////////////////////////// // LAYOUT // /** * {@inheritDoc} */ @Override public void requestLayout() { if (!mBlockLayoutRequests && !mInLayout) { super.requestLayout(); } } /** * {@inheritDoc} */ @Override protected void onLayout(final boolean changed, final int l, final int t, final int r, final int b) { // super.onLayout(changed, l, t, r, b); - skipping base AbsListView implementation on purpose // haven't set an adapter yet? get to it if (mAdapter == null) { return; } if (changed) { int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { getChildAt(i).forceLayout(); } mRecycleBin.markChildrenDirty(); } // TODO get the height of the view?? mInLayout = true; layoutChildren(); mInLayout = false; } /** * {@inheritDoc} */ @Override protected void layoutChildren() { if (mBlockLayoutRequests) return; mBlockLayoutRequests = true; try { super.layoutChildren(); invalidate(); if (mAdapter == null) { clearState(); invokeOnItemScrollListener(); return; } int childrenTop = getListPaddingTop(); int childCount = getChildCount(); View oldFirst = null; // our last state so we keep our position if (mLayoutMode == LAYOUT_NORMAL) { oldFirst = getChildAt(0); } boolean dataChanged = mDataChanged; if (dataChanged) { handleDataChanged(); } // safety check! // Handle the empty set by removing all views that are visible // and calling it a day if (mItemCount == 0) { clearState(); invokeOnItemScrollListener(); return; } else if (mItemCount != mAdapter.getCount()) { throw new IllegalStateException("The content of the adapter has changed but " + "ExtendableListView did not receive a notification. Make sure the content of " + "your adapter is not modified from a background thread, but only " + "from the UI thread. [in ExtendableListView(" + getId() + ", " + getClass() + ") with Adapter(" + mAdapter.getClass() + ")]"); } // Pull all children into the RecycleBin. // These views will be reused if possible final int firstPosition = mFirstPosition; final RecycleBin recycleBin = mRecycleBin; if (dataChanged) { for (int i = 0; i < childCount; i++) { recycleBin.addScrapView(getChildAt(i), firstPosition + i); } } else { recycleBin.fillActiveViews(childCount, firstPosition); } // Clear out old views detachAllViewsFromParent(); recycleBin.removeSkippedScrap(); switch (mLayoutMode) { case LAYOUT_FORCE_TOP: { mFirstPosition = 0; resetToTop(); adjustViewsUpOrDown(); fillFromTop(childrenTop); adjustViewsUpOrDown(); break; } case LAYOUT_SYNC: { fillSpecific(mSyncPosition, mSpecificTop); break; } case LAYOUT_NORMAL: default: { if (childCount == 0) { fillFromTop(childrenTop); } else if (mFirstPosition < mItemCount) { fillSpecific(mFirstPosition, oldFirst == null ? childrenTop : oldFirst.getTop()); } else { fillSpecific(0, childrenTop); } break; } } // Flush any cached views that did not get reused above recycleBin.scrapActiveViews(); mDataChanged = false; mNeedSync = false; mLayoutMode = LAYOUT_NORMAL; invokeOnItemScrollListener(); } finally { mBlockLayoutRequests = false; } } @Override protected void handleDataChanged() { super.handleDataChanged(); final int count = mItemCount; if (count > 0 && mNeedSync) { mNeedSync = false; mSyncState = null; mLayoutMode = LAYOUT_SYNC; mSyncPosition = Math.min(Math.max(0, mSyncPosition), count - 1); return; } mLayoutMode = LAYOUT_FORCE_TOP; mNeedSync = false; mSyncState = null; // TODO : add selection handling here } public void resetToTop() { // TO override } // ////////////////////////////////////////////////////////////////////////////////////////// // MEASUREMENT // /** * {@inheritDoc} */ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); setMeasuredDimension(widthSize, heightSize); mWidthMeasureSpec = widthMeasureSpec; } // ////////////////////////////////////////////////////////////////////////////////////////// // ON TOUCH // /** * {@inheritDoc} */ @Override public boolean onTouchEvent(MotionEvent event) { // we're not passing this down as // all the touch handling is right here // super.onTouchEvent(event); if (!isEnabled()) { // A disabled view that is clickable still consumes the touch // events, it just doesn't respond to them. return isClickable() || isLongClickable(); } initVelocityTrackerIfNotExists(); mVelocityTracker.addMovement(event); if (!hasChildren()) return false; boolean handled; final int action = event.getAction() & MotionEventCompat.ACTION_MASK; switch (action) { case MotionEvent.ACTION_DOWN: handled = onTouchDown(event); break; case MotionEvent.ACTION_MOVE: handled = onTouchMove(event); break; case MotionEvent.ACTION_CANCEL: handled = onTouchCancel(event); break; case MotionEvent.ACTION_POINTER_UP: handled = onTouchPointerUp(event); break; case MotionEvent.ACTION_UP: handled = onTouchUp(event); break; default: handled = false; break; } notifyTouchMode(); return handled; } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { int action = ev.getAction(); if (!mIsAttached) { // Something isn't right. // Since we rely on being attached to get data set change notifications, // don't risk doing anything where we might try to resync and find things // in a bogus state. return false; } switch (action & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_DOWN: { int touchMode = mTouchMode; // TODO : overscroll // if (touchMode == TOUCH_MODE_OVERFLING || touchMode == TOUCH_MODE_OVERSCROLL) { // mMotionCorrection = 0; // return true; // } final int x = (int) ev.getX(); final int y = (int) ev.getY(); mActivePointerId = ev.getPointerId(0); int motionPosition = findMotionRow(y); if (touchMode != TOUCH_MODE_FLINGING && motionPosition >= 0) { // User clicked on an actual view (and was not stopping a fling). // Remember where the motion event started mMotionX = x; mMotionY = y; mMotionPosition = motionPosition; mTouchMode = TOUCH_MODE_DOWN; } mLastY = Integer.MIN_VALUE; initOrResetVelocityTracker(); mVelocityTracker.addMovement(ev); if (touchMode == TOUCH_MODE_FLINGING) { return true; } break; } case MotionEvent.ACTION_MOVE: { switch (mTouchMode) { case TOUCH_MODE_DOWN: int pointerIndex = ev.findPointerIndex(mActivePointerId); if (pointerIndex == -1) { pointerIndex = 0; mActivePointerId = ev.getPointerId(pointerIndex); } final int y = (int) ev.getY(pointerIndex); initVelocityTrackerIfNotExists(); mVelocityTracker.addMovement(ev); if (startScrollIfNeeded(y)) { return true; } break; } break; } case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: { mTouchMode = TOUCH_MODE_IDLE; mActivePointerId = INVALID_POINTER; recycleVelocityTracker(); reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE); break; } case MotionEvent.ACTION_POINTER_UP: { onSecondaryPointerUp(ev); break; } } return false; } @Override public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) { if (disallowIntercept) { recycleVelocityTracker(); } super.requestDisallowInterceptTouchEvent(disallowIntercept); } final class CheckForTap implements Runnable { public void run() { if (mTouchMode == TOUCH_MODE_DOWN) { mTouchMode = TOUCH_MODE_TAP; final View child = getChildAt(mMotionPosition); if (child != null && !child.hasFocusable()) { mLayoutMode = LAYOUT_NORMAL; if (!mDataChanged) { layoutChildren(); child.setPressed(true); setPressed(true); final int longPressTimeout = ViewConfiguration.getLongPressTimeout(); final boolean longClickable = isLongClickable(); if (longClickable) { if (mPendingCheckForLongPress == null) { mPendingCheckForLongPress = new CheckForLongPress(); } mPendingCheckForLongPress.rememberWindowAttachCount(); postDelayed(mPendingCheckForLongPress, longPressTimeout); } else { mTouchMode = TOUCH_MODE_DONE_WAITING; } } else { mTouchMode = TOUCH_MODE_DONE_WAITING; } } } } } private boolean onTouchDown(final MotionEvent event) { final int x = (int) event.getX(); final int y = (int) event.getY(); int motionPosition = pointToPosition(x, y); mVelocityTracker.clear(); mActivePointerId = MotionEventCompat.getPointerId(event, 0); // TODO : use the motion position for fling support // TODO : support long press! // startLongPressCheck(); if ((mTouchMode != TOUCH_MODE_FLINGING) && !mDataChanged && motionPosition >= 0 && getAdapter().isEnabled(motionPosition)) { // is it a tap or a scroll .. we don't know yet! mTouchMode = TOUCH_MODE_DOWN; if (mPendingCheckForTap == null) { mPendingCheckForTap = new CheckForTap(); } postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout()); if (event.getEdgeFlags() != 0 && motionPosition < 0) { // If we couldn't find a view to click on, but the down event was touching // the edge, we will bail out and try again. This allows the edge correcting // code in ViewRoot to try to find a nearby view to select return false; } } else if (mTouchMode == TOUCH_MODE_FLINGING) { mTouchMode = TOUCH_MODE_SCROLLING; mMotionCorrection = 0; motionPosition = findMotionRow(y); } mMotionX = x; mMotionY = y; mMotionPosition = motionPosition; mLastY = Integer.MIN_VALUE; return true; } private boolean onTouchMove(final MotionEvent event) { final int index = MotionEventCompat.findPointerIndex(event, mActivePointerId); if (index < 0) { Log.e(TAG, "onTouchMove could not find pointer with id " + mActivePointerId + " - did ExtendableListView receive an inconsistent " + "event stream?"); return false; } final int y = (int) MotionEventCompat.getY(event, index); // our data's changed so we need to do a layout before moving any further if (mDataChanged) { layoutChildren(); } switch (mTouchMode) { case TOUCH_MODE_DOWN: case TOUCH_MODE_TAP: case TOUCH_MODE_DONE_WAITING: // Check if we have moved far enough that it looks more like a // scroll than a tap startScrollIfNeeded(y); break; case TOUCH_MODE_SCROLLING: // case TOUCH_MODE_OVERSCROLL: scrollIfNeeded(y); break; } return true; } private boolean onTouchCancel(final MotionEvent event) { mTouchMode = TOUCH_MODE_IDLE; setPressed(false); invalidate(); // redraw selector final Handler handler = getHandler(); if (handler != null) { handler.removeCallbacks(mPendingCheckForLongPress); } recycleVelocityTracker(); mActivePointerId = INVALID_POINTER; return true; } private boolean onTouchUp(final MotionEvent event) { switch (mTouchMode) { case TOUCH_MODE_DOWN: case TOUCH_MODE_TAP: case TOUCH_MODE_DONE_WAITING: return onTouchUpTap(event); case TOUCH_MODE_SCROLLING: return onTouchUpScrolling(event); } setPressed(false); invalidate(); // redraw selector final Handler handler = getHandler(); if (handler != null) { handler.removeCallbacks(mPendingCheckForLongPress); } recycleVelocityTracker(); mActivePointerId = INVALID_POINTER; return true; } private boolean onTouchUpScrolling(final MotionEvent event) { if (hasChildren()) { // 2 - Are we at the top or bottom? int top = getFirstChildTop(); int bottom = getLastChildBottom(); final boolean atEdge = mFirstPosition == 0 && top >= getListPaddingTop() && mFirstPosition + getChildCount() < mItemCount && bottom <= getHeight() - getListPaddingBottom(); if (!atEdge) { mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); final float velocity = mVelocityTracker.getYVelocity(mActivePointerId); if (Math.abs(velocity) > mFlingVelocity) { startFlingRunnable(velocity); mTouchMode = TOUCH_MODE_FLINGING; mMotionY = 0; invalidate(); return true; } } } stopFlingRunnable(); recycleVelocityTracker(); mTouchMode = TOUCH_MODE_IDLE; return true; } private boolean onTouchUpTap(final MotionEvent event) { final int motionPosition = mMotionPosition; if (motionPosition >= 0) { final View child = getChildAt(motionPosition); if (child != null && !child.hasFocusable()) { if (mTouchMode != TOUCH_MODE_DOWN) { child.setPressed(false); } if (mPerformClick == null) { invalidate(); mPerformClick = new PerformClick(); } final PerformClick performClick = mPerformClick; performClick.mClickMotionPosition = motionPosition; performClick.rememberWindowAttachCount(); // mResurrectToPosition = motionPosition; if (mTouchMode == TOUCH_MODE_DOWN || mTouchMode == TOUCH_MODE_TAP) { final Handler handler = getHandler(); if (handler != null) { handler.removeCallbacks(mTouchMode == TOUCH_MODE_DOWN ? mPendingCheckForTap : mPendingCheckForLongPress); } mLayoutMode = LAYOUT_NORMAL; if (!mDataChanged && motionPosition >= 0 && mAdapter.isEnabled(motionPosition)) { mTouchMode = TOUCH_MODE_TAP; layoutChildren(); child.setPressed(true); setPressed(true); postDelayed(new Runnable() { public void run() { child.setPressed(false); setPressed(false); if (!mDataChanged) { post(performClick); } mTouchMode = TOUCH_MODE_IDLE; } }, ViewConfiguration.getPressedStateDuration()); } else { mTouchMode = TOUCH_MODE_IDLE; } return true; } else if (!mDataChanged && motionPosition >= 0 && mAdapter.isEnabled(motionPosition)) { post(performClick); } } } mTouchMode = TOUCH_MODE_IDLE; return true; } private boolean onTouchPointerUp(final MotionEvent event) { onSecondaryPointerUp(event); final int x = mMotionX; final int y = mMotionY; final int motionPosition = pointToPosition(x, y); if (motionPosition >= 0) { mMotionPosition = motionPosition; } mLastY = y; return true; } private void onSecondaryPointerUp(MotionEvent event) { final int pointerIndex = (event.getAction() & MotionEventCompat.ACTION_POINTER_INDEX_MASK) >> MotionEventCompat.ACTION_POINTER_INDEX_SHIFT; final int pointerId = event.getPointerId(pointerIndex); if (pointerId == mActivePointerId) { // This was our active pointer going up. Choose a new // active pointer and adjust accordingly. // TODO: Make this decision more intelligent. final int newPointerIndex = pointerIndex == 0 ? 1 : 0; mMotionX = (int) event.getX(newPointerIndex); mMotionY = (int) event.getY(newPointerIndex); mActivePointerId = event.getPointerId(newPointerIndex); recycleVelocityTracker(); } } // ////////////////////////////////////////////////////////////////////////////////////////// // SCROLL HELPERS // /** * Starts a scroll that moves the difference between y and our last motions y * if it's a movement that represents a big enough scroll. */ private boolean startScrollIfNeeded(final int y) { final int deltaY = y - mMotionY; final int distance = Math.abs(deltaY); // TODO : Overscroll? // final boolean overscroll = mScrollY != 0; final boolean overscroll = false; if (overscroll || distance > mTouchSlop) { if (overscroll) { mMotionCorrection = 0; } else { mTouchMode = TOUCH_MODE_SCROLLING; mMotionCorrection = deltaY > 0 ? mTouchSlop : -mTouchSlop; } final Handler handler = getHandler(); if (handler != null) { handler.removeCallbacks(mPendingCheckForLongPress); } setPressed(false); View motionView = getChildAt(mMotionPosition - mFirstPosition); if (motionView != null) { motionView.setPressed(false); } final ViewParent parent = getParent(); if (parent != null) { parent.requestDisallowInterceptTouchEvent(true); } scrollIfNeeded(y); return true; } return false; } private void scrollIfNeeded(final int y) { if (DBG) Log.d(TAG, "scrollIfNeeded y: " + y); final int rawDeltaY = y - mMotionY; final int deltaY = rawDeltaY - mMotionCorrection; int incrementalDeltaY = mLastY != Integer.MIN_VALUE ? y - mLastY : deltaY; if (mTouchMode == TOUCH_MODE_SCROLLING) { if (DBG) Log.d(TAG, "scrollIfNeeded TOUCH_MODE_SCROLLING"); if (y != mLastY) { // stop our parent if (Math.abs(rawDeltaY) > mTouchSlop) { final ViewParent parent = getParent(); if (parent != null) { parent.requestDisallowInterceptTouchEvent(true); } } final int motionIndex; if (mMotionPosition >= 0) { motionIndex = mMotionPosition - mFirstPosition; } else { // If we don't have a motion position that we can reliably track, // pick something in the middle to make a best guess at things below. motionIndex = getChildCount() / 2; } // No need to do all this work if we're not going to move anyway boolean atEdge = false; if (incrementalDeltaY != 0) { atEdge = moveTheChildren(deltaY, incrementalDeltaY); } // Check to see if we have bumped into the scroll limit View motionView = this.getChildAt(motionIndex); if (motionView != null) { if (atEdge) { // TODO : edge effect & overscroll } mMotionY = y; } mLastY = y; } } // TODO : ELSE SUPPORT OVERSCROLL! } private int findMotionRow(int y) { int childCount = getChildCount(); if (childCount > 0) { // always from the top for (int i = 0; i < childCount; i++) { View v = getChildAt(i); if (y <= v.getBottom()) { return mFirstPosition + i; } } } return INVALID_POSITION; } // ////////////////////////////////////////////////////////////////////////////////////////// // MOVING STUFF! // // It's not scrolling - we're just moving views! // Move our views and implement view recycling to show new views if necessary // move our views by deltaY - what's the incrementalDeltaY? private boolean moveTheChildren(int deltaY, int incrementalDeltaY) { if (DBG) Log.d(TAG, "moveTheChildren deltaY: " + deltaY + "incrementalDeltaY: " + incrementalDeltaY); // there's nothing to move! if (!hasChildren()) return true; final int firstTop = getHighestChildTop(); final int lastBottom = getLowestChildBottom(); // "effective padding" In this case is the amount of padding that affects // how much space should not be filled by items. If we don't clip to padding // there is no effective padding. int effectivePaddingTop = 0; int effectivePaddingBottom = 0; if (mClipToPadding) { effectivePaddingTop = getListPaddingTop(); effectivePaddingBottom = getListPaddingBottom(); } final int gridHeight = getHeight(); final int spaceAbove = effectivePaddingTop - getFirstChildTop(); final int end = gridHeight - effectivePaddingBottom; final int spaceBelow = getLastChildBottom() - end; final int height = gridHeight - getListPaddingBottom() - getListPaddingTop(); if (incrementalDeltaY < 0) { incrementalDeltaY = Math.max(-(height - 1), incrementalDeltaY); } else { incrementalDeltaY = Math.min(height - 1, incrementalDeltaY); } final int firstPosition = mFirstPosition; int maxTop = getListPaddingTop(); int maxBottom = gridHeight - getListPaddingBottom(); int childCount = getChildCount(); final boolean cannotScrollDown = (firstPosition == 0 && firstTop >= maxTop && incrementalDeltaY >= 0); final boolean cannotScrollUp = (firstPosition + childCount == mItemCount && lastBottom <= maxBottom && incrementalDeltaY <= 0); if (DBG) { Log.d(TAG, "moveTheChildren " + " firstTop " + firstTop + " maxTop " + maxTop + " incrementalDeltaY " + incrementalDeltaY); Log.d(TAG, "moveTheChildren " + " lastBottom " + lastBottom + " maxBottom " + maxBottom + " incrementalDeltaY " + incrementalDeltaY); } if (cannotScrollDown) { if (DBG) Log.d(TAG, "moveTheChildren cannotScrollDown " + cannotScrollDown); return incrementalDeltaY != 0; } if (cannotScrollUp) { if (DBG) Log.d(TAG, "moveTheChildren cannotScrollUp " + cannotScrollUp); return incrementalDeltaY != 0; } final boolean isDown = incrementalDeltaY < 0; final int headerViewsCount = getHeaderViewsCount(); final int footerViewsStart = mItemCount - getFooterViewsCount(); int start = 0; int count = 0; if (isDown) { int top = -incrementalDeltaY; if (mClipToPadding) { top += getListPaddingTop(); } for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); if (child.getBottom() >= top) { break; } else { count++; int position = firstPosition + i; if (position >= headerViewsCount && position < footerViewsStart) { mRecycleBin.addScrapView(child, position); } } } } else { int bottom = gridHeight - incrementalDeltaY; if (mClipToPadding) { bottom -= getListPaddingBottom(); } for (int i = childCount - 1; i >= 0; i--) { final View child = getChildAt(i); if (child.getTop() <= bottom) { break; } else { start = i; count++; int position = firstPosition + i; if (position >= headerViewsCount && position < footerViewsStart) { mRecycleBin.addScrapView(child, position); } } } } mBlockLayoutRequests = true; if (count > 0) { if (DBG) Log.d(TAG, "scrap - detachViewsFromParent start:" + start + " count:" + count); detachViewsFromParent(start, count); mRecycleBin.removeSkippedScrap(); onChildrenDetached(start, count); } // invalidate before moving the children to avoid unnecessary invalidate // calls to bubble up from the children all the way to the top if (!awakenScrollBars()) { invalidate(); } offsetChildrenTopAndBottom(incrementalDeltaY); if (isDown) { mFirstPosition += count; } final int absIncrementalDeltaY = Math.abs(incrementalDeltaY); if (spaceAbove < absIncrementalDeltaY || spaceBelow < absIncrementalDeltaY) { fillGap(isDown); } // TODO : touch mode selector handling mBlockLayoutRequests = false; invokeOnItemScrollListener(); return false; } protected void onChildrenDetached(final int start, final int count) { } // ////////////////////////////////////////////////////////////////////////////////////////// // FILLING THE GRID! // /** * As we move and scroll and recycle views we want to fill the gap created with new views */ protected void fillGap(boolean down) { final int count = getChildCount(); if (down) { // fill down from the top of the position below our last int position = mFirstPosition + count; final int startOffset = getChildTop(position); fillDown(position, startOffset); } else { // fill up from the bottom of the position above our first. int position = mFirstPosition - 1; final int startOffset = getChildBottom(position); fillUp(position, startOffset); } adjustViewsAfterFillGap(down); } protected void adjustViewsAfterFillGap(boolean down) { if (down) { correctTooHigh(getChildCount()); } else { correctTooLow(getChildCount()); } } private View fillDown(int pos, int nextTop) { if (DBG) Log.d(TAG, "fillDown - pos:" + pos + " nextTop:" + nextTop); View selectedView = null; int end = getHeight(); if (mClipToPadding) { end -= getListPaddingBottom(); } while ((nextTop < end || hasSpaceDown()) && pos < mItemCount) { // TODO : add selection support makeAndAddView(pos, nextTop, true, false); pos++; nextTop = getNextChildDownsTop(pos); // = child.getBottom(); } return selectedView; } /*** * Override to tell filling flow to continue to fill up as we have space. */ protected boolean hasSpaceDown() { return false; } private View fillUp(int pos, int nextBottom) { if (DBG) Log.d(TAG, "fillUp - position:" + pos + " nextBottom:" + nextBottom); View selectedView = null; int end = mClipToPadding ? getListPaddingTop() : 0; while ((nextBottom > end || hasSpaceUp()) && pos >= 0) { // TODO : add selection support makeAndAddView(pos, nextBottom, false, false); pos--; nextBottom = getNextChildUpsBottom(pos); if (DBG) Log.d(TAG, "fillUp next - position:" + pos + " nextBottom:" + nextBottom); } mFirstPosition = pos + 1; return selectedView; } /*** * Override to tell filling flow to continue to fill up as we have space. */ protected boolean hasSpaceUp() { return false; } /** * Fills the list from top to bottom, starting with mFirstPosition */ private View fillFromTop(int nextTop) { mFirstPosition = Math.min(mFirstPosition, mItemCount - 1); if (mFirstPosition < 0) { mFirstPosition = 0; } return fillDown(mFirstPosition, nextTop); } /** * Put a specific item at a specific location on the screen and then build * up and down from there. * * @param position The reference view to use as the starting point * @param top Pixel offset from the top of this view to the top of the * reference view. * @return The selected view, or null if the selected view is outside the * visible area. */ private View fillSpecific(int position, int top) { boolean tempIsSelected = false; // ain't no body got time for that @ Etsy View temp = makeAndAddView(position, top, true, tempIsSelected); // Possibly changed again in fillUp if we add rows above this one. mFirstPosition = position; View above; View below; int nextBottom = getNextChildUpsBottom(position - 1); int nextTop = getNextChildDownsTop(position + 1); above = fillUp(position - 1, nextBottom); // This will correct for the top of the first view not touching the top of the list adjustViewsUpOrDown(); below = fillDown(position + 1, nextTop); int childCount = getChildCount(); if (childCount > 0) { correctTooHigh(childCount); } if (tempIsSelected) { return temp; } else if (above != null) { return above; } else { return below; } } /** * Gets a view either a new view an unused view?? or a recycled view and adds it to our children */ private View makeAndAddView(int position, int y, boolean flowDown, boolean selected) { View child; onChildCreated(position, flowDown); if (!mDataChanged) { // Try to use an existing view for this position child = mRecycleBin.getActiveView(position); if (child != null) { // Found it -- we're using an existing child // This just needs to be positioned setupChild(child, position, y, flowDown, selected, true); return child; } } // Make a new view for this position, or convert an unused view if possible child = obtainView(position, mIsScrap); // This needs to be positioned and measured setupChild(child, position, y, flowDown, selected, mIsScrap[0]); return child; } /** * Add a view as a child and make sure it is measured (if necessary) and * positioned properly. * * @param child The view to add * @param position The position of this child * @param y The y position relative to which this view will be positioned * @param flowDown If true, align top edge to y. If false, align bottom * edge to y. * @param selected Is this position selected? * @param recycled Has this view been pulled from the recycle bin? If so it * does not need to be remeasured. */ private void setupChild(View child, int position, int y, boolean flowDown, boolean selected, boolean recycled) { final boolean isSelected = false; // TODO : selected && shouldShowSelector(); final boolean updateChildSelected = isSelected != child.isSelected(); final int mode = mTouchMode; final boolean isPressed = mode > TOUCH_MODE_DOWN && mode < TOUCH_MODE_SCROLLING && mMotionPosition == position; final boolean updateChildPressed = isPressed != child.isPressed(); final boolean needToMeasure = !recycled || updateChildSelected || child.isLayoutRequested(); int itemViewType = mAdapter.getItemViewType(position); LayoutParams layoutParams; if (itemViewType == ITEM_VIEW_TYPE_HEADER_OR_FOOTER) { layoutParams = generateWrapperLayoutParams(child); } else { layoutParams = generateChildLayoutParams(child); } layoutParams.viewType = itemViewType; layoutParams.position = position; if (recycled || (layoutParams.recycledHeaderFooter && layoutParams.viewType == AdapterView.ITEM_VIEW_TYPE_HEADER_OR_FOOTER)) { if (DBG) Log.d(TAG, "setupChild attachViewToParent position:" + position); attachViewToParent(child, flowDown ? -1 : 0, layoutParams); } else { if (DBG) Log.d(TAG, "setupChild addViewInLayout position:" + position); if (layoutParams.viewType == AdapterView.ITEM_VIEW_TYPE_HEADER_OR_FOOTER) { layoutParams.recycledHeaderFooter = true; } addViewInLayout(child, flowDown ? -1 : 0, layoutParams, true); } if (updateChildSelected) { child.setSelected(isSelected); } if (updateChildPressed) { child.setPressed(isPressed); } if (needToMeasure) { if (DBG) Log.d(TAG, "setupChild onMeasureChild position:" + position); onMeasureChild(child, layoutParams); } else { if (DBG) Log.d(TAG, "setupChild cleanupLayoutState position:" + position); cleanupLayoutState(child); } final int w = child.getMeasuredWidth(); final int h = child.getMeasuredHeight(); final int childTop = flowDown ? y : y - h; if (DBG) { Log.d(TAG, "setupChild position:" + position + " h:" + h + " w:" + w); } final int childrenLeft = getChildLeft(position); if (needToMeasure) { final int childRight = childrenLeft + w; final int childBottom = childTop + h; onLayoutChild(child, position, flowDown, childrenLeft, childTop, childRight, childBottom); } else { onOffsetChild(child, position, flowDown, childrenLeft, childTop); } } protected LayoutParams generateChildLayoutParams(final View child) { return generateWrapperLayoutParams(child); } protected LayoutParams generateWrapperLayoutParams(final View child) { LayoutParams layoutParams = null; final ViewGroup.LayoutParams childParams = child.getLayoutParams(); if (childParams != null) { if (childParams instanceof LayoutParams) { layoutParams = (LayoutParams) childParams; } else { layoutParams = new LayoutParams(childParams); } } if (layoutParams == null) { layoutParams = generateDefaultLayoutParams(); } return layoutParams; } /** * Measures a child view in the list. Should call */ protected void onMeasureChild(final View child, final LayoutParams layoutParams) { int childWidthSpec = ViewGroup.getChildMeasureSpec(mWidthMeasureSpec, getListPaddingLeft() + getListPaddingRight(), layoutParams.width); int lpHeight = layoutParams.height; int childHeightSpec; if (lpHeight > 0) { childHeightSpec = MeasureSpec.makeMeasureSpec(lpHeight, MeasureSpec.EXACTLY); } else { childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); } child.measure(childWidthSpec, childHeightSpec); } protected LayoutParams generateDefaultLayoutParams() { return new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, 0); } protected LayoutParams generateHeaderFooterLayoutParams(final View child) { return new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, 0); } /** * Get a view and have it show the data associated with the specified * position. This is called when we have already discovered that the view is * not available for reuse in the recycle bin. The only choices left are * converting an old view or making a new one. * * @param position The position to display * @param isScrap Array of at least 1 boolean, the first entry will become true if * the returned view was taken from the scrap heap, false if otherwise. * @return A view displaying the data associated with the specified position */ private View obtainView(int position, boolean[] isScrap) { isScrap[0] = false; View scrapView; scrapView = mRecycleBin.getScrapView(position); View child; if (scrapView != null) { if (DBG) Log.d(TAG, "getView from scrap position:" + position); child = mAdapter.getView(position, scrapView, this); if (child != scrapView) { mRecycleBin.addScrapView(scrapView, position); } else { isScrap[0] = true; } } else { if (DBG) Log.d(TAG, "getView position:" + position); child = mAdapter.getView(position, null, this); } return child; } /** * Check if we have dragged the bottom of the list too high (we have pushed the * top element off the top of the screen when we did not need to). Correct by sliding * everything back down. * * @param childCount Number of children */ private void correctTooHigh(int childCount) { // First see if the last item is visible. If it is not, it is OK for the // top of the list to be pushed up. int lastPosition = mFirstPosition + childCount - 1; if (lastPosition == mItemCount - 1 && childCount > 0) { // ... and its bottom edge final int lastBottom = getLowestChildBottom(); // This is bottom of our drawable area final int end = (getBottom() - getTop()) - getListPaddingBottom(); // This is how far the bottom edge of the last view is from the bottom of the // drawable area int bottomOffset = end - lastBottom; final int firstTop = getHighestChildTop(); // Make sure we are 1) Too high, and 2) Either there are more rows above the // first row or the first row is scrolled off the top of the drawable area if (bottomOffset > 0 && (mFirstPosition > 0 || firstTop < getListPaddingTop())) { if (mFirstPosition == 0) { // Don't pull the top too far down bottomOffset = Math.min(bottomOffset, getListPaddingTop() - firstTop); } // Move everything down offsetChildrenTopAndBottom(bottomOffset); if (mFirstPosition > 0) { // Fill the gap that was opened above mFirstPosition with more rows, if // possible int previousPosition = mFirstPosition - 1; fillUp(previousPosition, getNextChildUpsBottom(previousPosition)); // Close up the remaining gap adjustViewsUpOrDown(); } } } } /** * Check if we have dragged the bottom of the list too low (we have pushed the * bottom element off the bottom of the screen when we did not need to). Correct by sliding * everything back up. * * @param childCount Number of children */ private void correctTooLow(int childCount) { // First see if the first item is visible. If it is not, it is OK for the // bottom of the list to be pushed down. if (mFirstPosition == 0 && childCount > 0) { // ... and its top edge final int firstTop = getHighestChildTop(); // This is top of our drawable area final int start = getListPaddingTop(); // This is bottom of our drawable area final int end = (getTop() - getBottom()) - getListPaddingBottom(); // This is how far the top edge of the first view is from the top of the // drawable area int topOffset = firstTop - start; final int lastBottom = getLowestChildBottom(); int lastPosition = mFirstPosition + childCount - 1; // Make sure we are 1) Too low, and 2) Either there are more rows below the // last row or the last row is scrolled off the bottom of the drawable area if (topOffset > 0) { if (lastPosition < mItemCount - 1 || lastBottom > end) { if (lastPosition == mItemCount - 1) { // Don't pull the bottom too far up topOffset = Math.min(topOffset, lastBottom - end); } // Move everything up offsetChildrenTopAndBottom(-topOffset); if (lastPosition < mItemCount - 1) { // Fill the gap that was opened below the last position with more rows, if // possible int nextPosition = lastPosition + 1; fillDown(nextPosition, getNextChildDownsTop(nextPosition)); // Close up the remaining gap adjustViewsUpOrDown(); } } else if (lastPosition == mItemCount - 1) { adjustViewsUpOrDown(); } } } } /** * Make sure views are touching the top or bottom edge, as appropriate for * our gravity */ private void adjustViewsUpOrDown() { final int childCount = getChildCount(); int delta; if (childCount > 0) { // Uh-oh -- we came up short. Slide all views up to make them // align with the top delta = getHighestChildTop() - getListPaddingTop(); if (delta < 0) { // We only are looking to see if we are too low, not too high delta = 0; } if (delta != 0) { offsetChildrenTopAndBottom(-delta); } } } // ////////////////////////////////////////////////////////////////////////////////////////// // PROTECTED POSITIONING EXTENSABLES // /** * Override */ protected void onChildCreated(final int position, final boolean flowDown) { } /** * Override to position the child as you so wish */ protected void onLayoutChild(final View child, final int position, final boolean flowDown, final int childrenLeft, final int childTop, final int childRight, final int childBottom) { child.layout(childrenLeft, childTop, childRight, childBottom); } /** * Override to offset the child as you so wish */ protected void onOffsetChild(final View child, final int position, final boolean flowDown, final int childrenLeft, final int childTop) { child.offsetLeftAndRight(childrenLeft - child.getLeft()); child.offsetTopAndBottom(childTop - child.getTop()); } /** * Override to set you custom listviews child to a specific left location * * @return the left location to layout the child for the given position */ protected int getChildLeft(final int position) { return getListPaddingLeft(); } /** * Override to set you custom listviews child to a specific top location * * @return the top location to layout the child for the given position */ protected int getChildTop(final int position) { int count = getChildCount(); int paddingTop = 0; if (mClipToPadding) { paddingTop = getListPaddingTop(); } return count > 0 ? getChildAt(count - 1).getBottom() : paddingTop; } /** * Override to set you custom listviews child to a bottom top location * * @return the bottom location to layout the child for the given position */ protected int getChildBottom(final int position) { int count = getChildCount(); int paddingBottom = 0; if (mClipToPadding) { paddingBottom = getListPaddingBottom(); } return count > 0 ? getChildAt(0).getTop() : getHeight() - paddingBottom; } protected int getNextChildDownsTop(final int position) { final int count = getChildCount(); return count > 0 ? getChildAt(count - 1).getBottom() : 0; } protected int getNextChildUpsBottom(final int position) { final int count = getChildCount(); if (count == 0) { return 0; } return count > 0 ? getChildAt(0).getTop() : 0; } protected int getFirstChildTop() { return hasChildren() ? getChildAt(0).getTop() : 0; } protected int getHighestChildTop() { return hasChildren() ? getChildAt(0).getTop() : 0; } protected int getLastChildBottom() { return hasChildren() ? getChildAt(getChildCount() - 1).getBottom() : 0; } protected int getLowestChildBottom() { return hasChildren() ? getChildAt(getChildCount() - 1).getBottom() : 0; } protected boolean hasChildren() { return getChildCount() > 0; } protected void offsetChildrenTopAndBottom(int offset) { if (DBG) Log.d(TAG, "offsetChildrenTopAndBottom: " + offset); final int count = getChildCount(); for (int i = 0; i < count; i++) { final View v = getChildAt(i); v.offsetTopAndBottom(offset); } } @Override public int getFirstVisiblePosition() { return Math.max(0, mFirstPosition - getHeaderViewsCount()); } @Override public int getLastVisiblePosition() { return Math.min(mFirstPosition + getChildCount() - 1, mAdapter != null ? mAdapter.getCount() - 1 : 0); } // ////////////////////////////////////////////////////////////////////////////////////////// // FLING // private void initOrResetVelocityTracker() { if (mVelocityTracker == null) { mVelocityTracker = VelocityTracker.obtain(); } else { mVelocityTracker.clear(); } } private void initVelocityTrackerIfNotExists() { if (mVelocityTracker == null) { mVelocityTracker = VelocityTracker.obtain(); } } private void recycleVelocityTracker() { if (mVelocityTracker != null) { mVelocityTracker.recycle(); mVelocityTracker = null; } } private void startFlingRunnable(final float velocity) { if (mFlingRunnable == null) { mFlingRunnable = new FlingRunnable(); } mFlingRunnable.start((int) -velocity); } private void stopFlingRunnable() { if (mFlingRunnable != null) { mFlingRunnable.endFling(); } } // ////////////////////////////////////////////////////////////////////////////////////////// // FLING RUNNABLE // /** * Responsible for fling behavior. Use {@link #start(int)} to * initiate a fling. Each frame of the fling is handled in {@link #run()}. * A FlingRunnable will keep re-posting itself until the fling is done. */ private class FlingRunnable implements Runnable { /** * Tracks the decay of a fling scroll */ private final Scroller mScroller; /** * Y value reported by mScroller on the previous fling */ private int mLastFlingY; FlingRunnable() { mScroller = new Scroller(getContext()); } void start(int initialVelocity) { int initialY = initialVelocity < 0 ? Integer.MAX_VALUE : 0; mLastFlingY = initialY; mScroller.forceFinished(true); mScroller.fling(0, initialY, 0, initialVelocity, 0, Integer.MAX_VALUE, 0, Integer.MAX_VALUE); mTouchMode = TOUCH_MODE_FLINGING; postOnAnimate(this); } void startScroll(int distance, int duration) { int initialY = distance < 0 ? Integer.MAX_VALUE : 0; mLastFlingY = initialY; mScroller.startScroll(0, initialY, 0, distance, duration); mTouchMode = TOUCH_MODE_FLINGING; postOnAnimate(this); } private void endFling() { mLastFlingY = 0; mTouchMode = TOUCH_MODE_IDLE; reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE); removeCallbacks(this); mScroller.forceFinished(true); } public void run() { switch (mTouchMode) { default: return; case TOUCH_MODE_FLINGING: { if (mItemCount == 0 || getChildCount() == 0) { endFling(); return; } final Scroller scroller = mScroller; boolean more = scroller.computeScrollOffset(); final int y = scroller.getCurrY(); // Flip sign to convert finger direction to list items direction // (e.g. finger moving down means list is moving towards the top) int delta = mLastFlingY - y; // Pretend that each frame of a fling scroll is a touch scroll if (delta > 0) { // List is moving towards the top. Use first view as mMotionPosition mMotionPosition = mFirstPosition; // Don't fling more than 1 screen delta = Math.min(getHeight() - getPaddingBottom() - getPaddingTop() - 1, delta); } else { // List is moving towards the bottom. Use last view as mMotionPosition int offsetToLast = getChildCount() - 1; mMotionPosition = mFirstPosition + offsetToLast; // Don't fling more than 1 screen delta = Math.max(-(getHeight() - getPaddingBottom() - getPaddingTop() - 1), delta); } final boolean atEnd = moveTheChildren(delta, delta); if (more && !atEnd) { invalidate(); mLastFlingY = y; postOnAnimate(this); } else { endFling(); } break; } } } } private void postOnAnimate(Runnable runnable) { ViewCompat.postOnAnimation(this, runnable); } // ////////////////////////////////////////////////////////////////////////////////////////// // SCROLL LISTENER // /** * Notify any scroll listeners of our current touch mode */ public void notifyTouchMode() { // only tell the scroll listener about some things we want it to know switch (mTouchMode) { case TOUCH_MODE_SCROLLING: reportScrollStateChange(OnScrollListener.SCROLL_STATE_TOUCH_SCROLL); break; case TOUCH_MODE_FLINGING: reportScrollStateChange(OnScrollListener.SCROLL_STATE_FLING); break; case TOUCH_MODE_IDLE: reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE); break; } } private OnScrollListener mOnScrollListener; public void setOnScrollListener(OnScrollListener scrollListener) { super.setOnScrollListener(scrollListener); mOnScrollListener = scrollListener; } void reportScrollStateChange(int newState) { if (newState != mScrollState) { mScrollState = newState; if (mOnScrollListener != null) { mOnScrollListener.onScrollStateChanged(this, newState); } } } void invokeOnItemScrollListener() { if (mOnScrollListener != null) { mOnScrollListener.onScroll(this, mFirstPosition, getChildCount(), mItemCount); } } /** * Update the status of the list based on the empty parameter. If empty is true and * we have an empty view, display it. In all the other cases, make sure that the listview * is VISIBLE and that the empty view is GONE (if it's not null). */ private void updateEmptyStatus() { boolean empty = getAdapter() == null || getAdapter().isEmpty(); if (isInFilterMode()) { empty = false; } View emptyView = getEmptyView(); if (empty) { if (emptyView != null) { emptyView.setVisibility(View.VISIBLE); setVisibility(View.GONE); } else { // If the caller just removed our empty view, make sure the list view is visible setVisibility(View.VISIBLE); } // We are now GONE, so pending layouts will not be dispatched. // Force one here to make sure that the state of the list matches // the state of the adapter. if (mDataChanged) { this.onLayout(false, getLeft(), getTop(), getRight(), getBottom()); } } else { if (emptyView != null) { emptyView.setVisibility(View.GONE); } setVisibility(View.VISIBLE); } } // ////////////////////////////////////////////////////////////////////////////////////////// // ADAPTER OBSERVER // class AdapterDataSetObserver extends DataSetObserver { private Parcelable mInstanceState = null; @Override public void onChanged() { mDataChanged = true; mOldItemCount = mItemCount; mItemCount = getAdapter().getCount(); mRecycleBin.clearTransientStateViews(); // Detect the case where a cursor that was previously invalidated has // been repopulated with new data. if (ExtendableListView.this.getAdapter().hasStableIds() && mInstanceState != null && mOldItemCount == 0 && mItemCount > 0) { ExtendableListView.this.onRestoreInstanceState(mInstanceState); mInstanceState = null; } else { rememberSyncState(); } updateEmptyStatus(); requestLayout(); } @Override public void onInvalidated() { mDataChanged = true; if (ExtendableListView.this.getAdapter().hasStableIds()) { // Remember the current state for the case where our hosting activity is being // stopped and later restarted mInstanceState = ExtendableListView.this.onSaveInstanceState(); } // Data is invalid so we should reset our state mOldItemCount = mItemCount; mItemCount = 0; mNeedSync = false; updateEmptyStatus(); requestLayout(); } public void clearSavedState() { mInstanceState = null; } } // ////////////////////////////////////////////////////////////////////////////////////////// // LAYOUT PARAMS // /** * Re-implementing some properties in {@link android.view.ViewGroup.LayoutParams} since they're package * private but we want to appear to be an extension of the existing class. */ public static class LayoutParams extends AbsListView.LayoutParams { boolean recycledHeaderFooter; // Position of the view in the data int position; // adapter ID the view represents fetched from the adapter if it's stable long itemId = -1; // adapter view type int viewType; public LayoutParams(Context c, AttributeSet attrs) { super(c, attrs); } public LayoutParams(int w, int h) { super(w, h); } public LayoutParams(int w, int h, int viewType) { super(w, h); this.viewType = viewType; } public LayoutParams(ViewGroup.LayoutParams source) { super(source); } } // ////////////////////////////////////////////////////////////////////////////////////////// // RecycleBin // /** * Note there's no RecyclerListener. The caller shouldn't have a need and we can add it later. */ class RecycleBin { /** * The position of the first view stored in mActiveViews. */ private int mFirstActivePosition; /** * Views that were on screen at the start of layout. This array is populated at the start of * layout, and at the end of layout all view in mActiveViews are moved to mScrapViews. * Views in mActiveViews represent a contiguous range of Views, with position of the first * view store in mFirstActivePosition. */ private View[] mActiveViews = new View[0]; /** * Unsorted views that can be used by the adapter as a convert view. */ private ArrayList<View>[] mScrapViews; private int mViewTypeCount; private ArrayList<View> mCurrentScrap; private ArrayList<View> mSkippedScrap; private SparseArrayCompat<View> mTransientStateViews; public void setViewTypeCount(int viewTypeCount) { if (viewTypeCount < 1) { throw new IllegalArgumentException("Can't have a viewTypeCount < 1"); } //noinspection unchecked ArrayList<View>[] scrapViews = new ArrayList[viewTypeCount]; for (int i = 0; i < viewTypeCount; i++) { scrapViews[i] = new ArrayList<View>(); } mViewTypeCount = viewTypeCount; mCurrentScrap = scrapViews[0]; mScrapViews = scrapViews; } public void markChildrenDirty() { if (mViewTypeCount == 1) { final ArrayList<View> scrap = mCurrentScrap; final int scrapCount = scrap.size(); for (int i = 0; i < scrapCount; i++) { scrap.get(i).forceLayout(); } } else { final int typeCount = mViewTypeCount; for (int i = 0; i < typeCount; i++) { final ArrayList<View> scrap = mScrapViews[i]; final int scrapCount = scrap.size(); for (int j = 0; j < scrapCount; j++) { scrap.get(j).forceLayout(); } } } if (mTransientStateViews != null) { final int count = mTransientStateViews.size(); for (int i = 0; i < count; i++) { mTransientStateViews.valueAt(i).forceLayout(); } } } public boolean shouldRecycleViewType(int viewType) { return viewType >= 0; } /** * Clears the scrap heap. */ void clear() { if (mViewTypeCount == 1) { final ArrayList<View> scrap = mCurrentScrap; final int scrapCount = scrap.size(); for (int i = 0; i < scrapCount; i++) { removeDetachedView(scrap.remove(scrapCount - 1 - i), false); } } else { final int typeCount = mViewTypeCount; for (int i = 0; i < typeCount; i++) { final ArrayList<View> scrap = mScrapViews[i]; final int scrapCount = scrap.size(); for (int j = 0; j < scrapCount; j++) { removeDetachedView(scrap.remove(scrapCount - 1 - j), false); } } } if (mTransientStateViews != null) { mTransientStateViews.clear(); } } /** * Fill ActiveViews with all of the children of the AbsListView. * * @param childCount The minimum number of views mActiveViews should hold * @param firstActivePosition The position of the first view that will be stored in * mActiveViews */ void fillActiveViews(int childCount, int firstActivePosition) { if (mActiveViews.length < childCount) { mActiveViews = new View[childCount]; } mFirstActivePosition = firstActivePosition; final View[] activeViews = mActiveViews; for (int i = 0; i < childCount; i++) { View child = getChildAt(i); LayoutParams lp = (LayoutParams) child.getLayoutParams(); // Don't put header or footer views into the scrap heap if (lp != null && lp.viewType != ITEM_VIEW_TYPE_HEADER_OR_FOOTER) { // Note: We do place AdapterView.ITEM_VIEW_TYPE_IGNORE in active views. // However, we will NOT place them into scrap views. activeViews[i] = child; } } } /** * Get the view corresponding to the specified position. The view will be removed from * mActiveViews if it is found. * * @param position The position to look up in mActiveViews * @return The view if it is found, null otherwise */ View getActiveView(int position) { int index = position - mFirstActivePosition; final View[] activeViews = mActiveViews; if (index >= 0 && index < activeViews.length) { final View match = activeViews[index]; activeViews[index] = null; return match; } return null; } View getTransientStateView(int position) { if (mTransientStateViews == null) { return null; } final int index = mTransientStateViews.indexOfKey(position); if (index < 0) { return null; } final View result = mTransientStateViews.valueAt(index); mTransientStateViews.removeAt(index); return result; } /** * Dump any currently saved views with transient state. */ void clearTransientStateViews() { if (mTransientStateViews != null) { mTransientStateViews.clear(); } } /** * @return A view from the ScrapViews collection. These are unordered. */ View getScrapView(int position) { if (mViewTypeCount == 1) { return retrieveFromScrap(mCurrentScrap, position); } else { int whichScrap = mAdapter.getItemViewType(position); if (whichScrap >= 0 && whichScrap < mScrapViews.length) { return retrieveFromScrap(mScrapViews[whichScrap], position); } } return null; } /** * Put a view into the ScrapViews list. These views are unordered. * * @param scrap The view to add */ void addScrapView(View scrap, int position) { if (DBG) Log.d(TAG, "addScrapView position = " + position); LayoutParams lp = (LayoutParams) scrap.getLayoutParams(); if (lp == null) { return; } lp.position = position; // Don't put header or footer views or views that should be ignored // into the scrap heap int viewType = lp.viewType; final boolean scrapHasTransientState = ViewCompat.hasTransientState(scrap); if (!shouldRecycleViewType(viewType) || scrapHasTransientState) { if (viewType != ITEM_VIEW_TYPE_HEADER_OR_FOOTER || scrapHasTransientState) { if (mSkippedScrap == null) { mSkippedScrap = new ArrayList<View>(); } mSkippedScrap.add(scrap); } if (scrapHasTransientState) { if (mTransientStateViews == null) { mTransientStateViews = new SparseArrayCompat<View>(); } mTransientStateViews.put(position, scrap); } return; } if (mViewTypeCount == 1) { mCurrentScrap.add(scrap); } else { mScrapViews[viewType].add(scrap); } } /** * Finish the removal of any views that skipped the scrap heap. */ void removeSkippedScrap() { if (mSkippedScrap == null) { return; } final int count = mSkippedScrap.size(); for (int i = 0; i < count; i++) { removeDetachedView(mSkippedScrap.get(i), false); } mSkippedScrap.clear(); } /** * Move all views remaining in mActiveViews to mScrapViews. */ void scrapActiveViews() { final View[] activeViews = mActiveViews; final boolean multipleScraps = mViewTypeCount > 1; ArrayList<View> scrapViews = mCurrentScrap; final int count = activeViews.length; for (int i = count - 1; i >= 0; i--) { final View victim = activeViews[i]; if (victim != null) { final LayoutParams lp = (LayoutParams) victim.getLayoutParams(); activeViews[i] = null; final boolean scrapHasTransientState = ViewCompat.hasTransientState(victim); int viewType = lp.viewType; if (!shouldRecycleViewType(viewType) || scrapHasTransientState) { // Do not move views that should be ignored if (viewType != ITEM_VIEW_TYPE_HEADER_OR_FOOTER || scrapHasTransientState) { removeDetachedView(victim, false); } if (scrapHasTransientState) { if (mTransientStateViews == null) { mTransientStateViews = new SparseArrayCompat<View>(); } mTransientStateViews.put(mFirstActivePosition + i, victim); } continue; } if (multipleScraps) { scrapViews = mScrapViews[viewType]; } lp.position = mFirstActivePosition + i; scrapViews.add(victim); } } pruneScrapViews(); } /** * Makes sure that the size of mScrapViews does not exceed the size of mActiveViews. * (This can happen if an adapter does not recycle its views). */ private void pruneScrapViews() { final int maxViews = mActiveViews.length; final int viewTypeCount = mViewTypeCount; final ArrayList<View>[] scrapViews = mScrapViews; for (int i = 0; i < viewTypeCount; ++i) { final ArrayList<View> scrapPile = scrapViews[i]; int size = scrapPile.size(); final int extras = size - maxViews; size--; for (int j = 0; j < extras; j++) { removeDetachedView(scrapPile.remove(size--), false); } } if (mTransientStateViews != null) { for (int i = 0; i < mTransientStateViews.size(); i++) { final View v = mTransientStateViews.valueAt(i); if (!ViewCompat.hasTransientState(v)) { mTransientStateViews.removeAt(i); i--; } } } } /** * Updates the cache color hint of all known views. * * @param color The new cache color hint. */ void setCacheColorHint(int color) { if (mViewTypeCount == 1) { final ArrayList<View> scrap = mCurrentScrap; final int scrapCount = scrap.size(); for (int i = 0; i < scrapCount; i++) { scrap.get(i).setDrawingCacheBackgroundColor(color); } } else { final int typeCount = mViewTypeCount; for (int i = 0; i < typeCount; i++) { final ArrayList<View> scrap = mScrapViews[i]; final int scrapCount = scrap.size(); for (int j = 0; j < scrapCount; j++) { scrap.get(j).setDrawingCacheBackgroundColor(color); } } } // Just in case this is called during a layout pass final View[] activeViews = mActiveViews; final int count = activeViews.length; for (int i = 0; i < count; ++i) { final View victim = activeViews[i]; if (victim != null) { victim.setDrawingCacheBackgroundColor(color); } } } } static View retrieveFromScrap(ArrayList<View> scrapViews, int position) { int size = scrapViews.size(); if (size > 0) { // See if we still have a view for this position. for (int i = 0; i < size; i++) { View view = scrapViews.get(i); if (((LayoutParams) view.getLayoutParams()).position == position) { scrapViews.remove(i); return view; } } return scrapViews.remove(size - 1); } else { return null; } } // ////////////////////////////////////////////////////////////////////////////////////////// // OUR STATE // /** * Position from which to start looking for mSyncRowId */ protected int mSyncPosition; /** * The offset in pixels from the top of the AdapterView to the top * of the view to select during the next layout. */ protected int mSpecificTop; /** * Row id to look for when data has changed */ long mSyncRowId = INVALID_ROW_ID; /** * Height of the view when mSyncPosition and mSyncRowId where set */ long mSyncHeight; /** * True if we need to sync to mSyncRowId */ boolean mNeedSync = false; private ListSavedState mSyncState; /** * Remember enough information to restore the screen state when the data has * changed. */ void rememberSyncState() { if (getChildCount() > 0) { mNeedSync = true; mSyncHeight = getHeight(); // Sync the based on the offset of the first view View v = getChildAt(0); ListAdapter adapter = getAdapter(); if (mFirstPosition >= 0 && mFirstPosition < adapter.getCount()) { mSyncRowId = adapter.getItemId(mFirstPosition); } else { mSyncRowId = NO_ID; } if (v != null) { mSpecificTop = v.getTop(); } mSyncPosition = mFirstPosition; } } private void clearState() { // cleanup headers and footers before removing the views clearRecycledState(mHeaderViewInfos); clearRecycledState(mFooterViewInfos); removeAllViewsInLayout(); mFirstPosition = 0; mDataChanged = false; mRecycleBin.clear(); mNeedSync = false; mSyncState = null; mLayoutMode = LAYOUT_NORMAL; invalidate(); } private void clearRecycledState(ArrayList<FixedViewInfo> infos) { if (infos == null) return; for (FixedViewInfo info : infos) { final View child = info.view; final ViewGroup.LayoutParams p = child.getLayoutParams(); if (p instanceof LayoutParams) { ((LayoutParams) p).recycledHeaderFooter = false; } } } public static class ListSavedState extends ClassLoaderSavedState { protected long selectedId; protected long firstId; protected int viewTop; protected int position; protected int height; /** * Constructor called from {@link android.widget.AbsListView#onSaveInstanceState()} */ public ListSavedState(Parcelable superState) { super(superState, AbsListView.class.getClassLoader()); } /** * Constructor called from {@link #CREATOR} */ public ListSavedState(Parcel in) { super(in); selectedId = in.readLong(); firstId = in.readLong(); viewTop = in.readInt(); position = in.readInt(); height = in.readInt(); } @Override public void writeToParcel(Parcel out, int flags) { super.writeToParcel(out, flags); out.writeLong(selectedId); out.writeLong(firstId); out.writeInt(viewTop); out.writeInt(position); out.writeInt(height); } @Override public String toString() { return "ExtendableListView.ListSavedState{" + Integer.toHexString(System.identityHashCode(this)) + " selectedId=" + selectedId + " firstId=" + firstId + " viewTop=" + viewTop + " position=" + position + " height=" + height + "}"; } public static final Creator<ListSavedState> CREATOR = new Creator<ListSavedState>() { public ListSavedState createFromParcel(Parcel in) { return new ListSavedState(in); } public ListSavedState[] newArray(int size) { return new ListSavedState[size]; } }; } @Override public Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); ListSavedState ss = new ListSavedState(superState); if (mSyncState != null) { // Just keep what we last restored. ss.selectedId = mSyncState.selectedId; ss.firstId = mSyncState.firstId; ss.viewTop = mSyncState.viewTop; ss.position = mSyncState.position; ss.height = mSyncState.height; return ss; } boolean haveChildren = getChildCount() > 0 && mItemCount > 0; ss.selectedId = getSelectedItemId(); ss.height = getHeight(); // TODO : sync selection when we handle it if (haveChildren && mFirstPosition > 0) { // Remember the position of the first child. // We only do this if we are not currently at the top of // the list, for two reasons: // (1) The list may be in the process of becoming empty, in // which case mItemCount may not be 0, but if we try to // ask for any information about position 0 we will crash. // (2) Being "at the top" seems like a special case, anyway, // and the user wouldn't expect to end up somewhere else when // they revisit the list even if its content has changed. View v = getChildAt(0); ss.viewTop = v.getTop(); int firstPos = mFirstPosition; if (firstPos >= mItemCount) { firstPos = mItemCount - 1; } ss.position = firstPos; ss.firstId = mAdapter.getItemId(firstPos); } else { ss.viewTop = 0; ss.firstId = INVALID_POSITION; ss.position = 0; } return ss; } @Override public void onRestoreInstanceState(Parcelable state) { ListSavedState ss = (ListSavedState) state; super.onRestoreInstanceState(ss.getSuperState()); mDataChanged = true; mSyncHeight = ss.height; if (ss.firstId >= 0) { mNeedSync = true; mSyncState = ss; mSyncRowId = ss.firstId; mSyncPosition = ss.position; mSpecificTop = ss.viewTop; } requestLayout(); } private class PerformClick extends WindowRunnnable implements Runnable { int mClickMotionPosition; public void run() { if (mDataChanged) return; final ListAdapter adapter = mAdapter; final int motionPosition = mClickMotionPosition; if (adapter != null && mItemCount > 0 && motionPosition != INVALID_POSITION && motionPosition < adapter.getCount() && sameWindow()) { final View view = getChildAt(motionPosition); // a fix by @pboos if (view != null) { final int clickPosition = motionPosition + mFirstPosition; performItemClick(view, clickPosition, adapter.getItemId(clickPosition)); } } } } private boolean performLongPress(final View child, final int longPressPosition, final long longPressId) { boolean handled = false; OnItemLongClickListener onItemLongClickListener = getOnItemLongClickListener(); if (onItemLongClickListener != null) { handled = onItemLongClickListener.onItemLongClick(ExtendableListView.this, child, longPressPosition, longPressId); } // if (!handled) { // mContextMenuInfo = createContextMenuInfo(child, longPressPosition, longPressId); // handled = super.showContextMenuForChild(AbsListView.this); // } if (handled) { performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); } return handled; } /** * A base class for Runnables that will check that their view is still attached to * the original window as when the Runnable was created. */ private class WindowRunnnable { private int mOriginalAttachCount; public void rememberWindowAttachCount() { mOriginalAttachCount = getWindowAttachCount(); } public boolean sameWindow() { return hasWindowFocus() && getWindowAttachCount() == mOriginalAttachCount; } } }