Bug 1089667 - Sync TwoWayView with upstream's 0.1.2 release (r=mcomella)
authorLucas Rocha <lucasr@lucasr.org>
Wed, 29 Oct 2014 17:39:11 +0000
changeset 212980 940d90e7dae8ed4db14a338e767b09d31b824c57
parent 212979 a68a5f6029ff4133e27edf786aa464c437cae6f5
child 212981 3e842e9a5746b7f50709d1a804a41baa320022d6
push id51106
push userryanvm@gmail.com
push dateWed, 29 Oct 2014 20:52:00 +0000
treeherdermozilla-inbound@8512443e6e4f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmcomella
bugs1089667
milestone36.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1089667 - Sync TwoWayView with upstream's 0.1.2 release (r=mcomella)
mobile/android/base/widget/TwoWayView.java
--- a/mobile/android/base/widget/TwoWayView.java
+++ b/mobile/android/base/widget/TwoWayView.java
@@ -136,17 +136,19 @@ public class TwoWayView extends AdapterV
         MULTIPLE
     }
 
     public static enum Orientation {
         HORIZONTAL,
         VERTICAL
     }
 
-    ListAdapter mAdapter;
+    private final Context mContext;
+
+    private ListAdapter mAdapter;
 
     private boolean mIsVertical;
 
     private int mItemMargin;
 
     private boolean mInLayout;
     private boolean mBlockLayoutRequests;
 
@@ -154,85 +156,88 @@ public class TwoWayView extends AdapterV
 
     private final RecycleBin mRecycler;
     private AdapterDataSetObserver mDataSetObserver;
 
     private boolean mItemsCanFocus;
 
     final boolean[] mIsScrap = new boolean[1];
 
-    boolean mDataChanged;
-    int mItemCount;
-    int mOldItemCount;
-    boolean mHasStableIds;
+    private boolean mDataChanged;
+    private int mItemCount;
+    private int mOldItemCount;
+    private boolean mHasStableIds;
     private boolean mAreAllItemsSelectable;
 
-    int mFirstPosition;
+    private int mFirstPosition;
     private int mSpecificStart;
 
     private SavedState mPendingSync;
 
+    private PositionScroller mPositionScroller;
+    private Runnable mPositionScrollAfterLayout;
+
     private final int mTouchSlop;
     private final int mMaximumVelocity;
     private final int mFlingVelocity;
     private float mLastTouchPos;
     private float mTouchRemainderPos;
     private int mActivePointerId;
 
     private final Rect mTempRect;
 
     private final ArrowScrollFocusResult mArrowScrollFocusResult;
 
-    int mMotionPosition;
-    Runnable mTouchModeReset;
     private Rect mTouchFrame;
+    private int mMotionPosition;
     private CheckForTap mPendingCheckForTap;
     private CheckForLongPress mPendingCheckForLongPress;
     private CheckForKeyLongPress mPendingCheckForKeyLongPress;
     private PerformClick mPerformClick;
+    private Runnable mTouchModeReset;
     private int mResurrectToPosition;
 
     private boolean mIsChildViewEnabled;
 
-    Drawable mSelector;
     private boolean mDrawSelectorOnTop;
+    private Drawable mSelector;
     private int mSelectorPosition;
     private final Rect mSelectorRect;
 
     private int mOverScroll;
     private final int mOverscrollDistance;
 
     private boolean mDesiredFocusableState;
     private boolean mDesiredFocusableInTouchModeState;
 
     private SelectionNotifier mSelectionNotifier;
 
-    boolean mNeedSync;
+    private boolean mNeedSync;
     private int mSyncMode;
     private int mSyncPosition;
     private long mSyncRowId;
-    private long mSyncHeight;
+    private long mSyncSize;
     private int mSelectedStart;
 
-    int mNextSelectedPosition;
-    long mNextSelectedRowId;
-    int mSelectedPosition;
-    long mSelectedRowId;
+    private int mNextSelectedPosition;
+    private long mNextSelectedRowId;
+    private int mSelectedPosition;
+    private long mSelectedRowId;
     private int mOldSelectedPosition;
     private long mOldSelectedRowId;
 
     private ChoiceMode mChoiceMode;
     private int mCheckedItemCount;
     private SparseBooleanArray mCheckStates;
     LongSparseArray<Integer> mCheckedIdStates;
 
     private ContextMenuInfo mContextMenuInfo;
 
-    int mLayoutMode;
-    int mTouchMode;
+    private int mLayoutMode;
+    private int mTouchMode;
     private int mLastTouchMode;
     private VelocityTracker mVelocityTracker;
     private final Scroller mScroller;
 
     private EdgeEffectCompat mStartEdge;
     private EdgeEffectCompat mEndEdge;
 
     private OnScrollListener mOnScrollListener;
@@ -315,16 +320,18 @@ public class TwoWayView extends AdapterV
 
     public TwoWayView(Context context, AttributeSet attrs) {
         this(context, attrs, 0);
     }
 
     public TwoWayView(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
 
+        mContext = context;
+
         mLayoutMode = LAYOUT_NORMAL;
         mTouchMode = TOUCH_MODE_REST;
         mLastTouchMode = TOUCH_MODE_UNKNOWN;
 
         mLastScrollState = OnScrollListener.SCROLL_STATE_IDLE;
 
         final ViewConfiguration vc = ViewConfiguration.get(context);
         mTouchSlop = vc.getScaledTouchSlop();
@@ -977,26 +984,37 @@ public class TwoWayView extends AdapterV
             removeCallbacks(mPerformClick);
         }
 
         if (mTouchModeReset != null) {
             removeCallbacks(mTouchModeReset);
             mTouchModeReset.run();
         }
 
+        finishSmoothScrolling();
+
         mIsAttached = false;
     }
 
     @Override
     public void onWindowFocusChanged(boolean hasWindowFocus) {
         super.onWindowFocusChanged(hasWindowFocus);
 
         final int touchMode = isInTouchMode() ? TOUCH_MODE_ON : TOUCH_MODE_OFF;
 
         if (!hasWindowFocus) {
+            if (!mScroller.isFinished()) {
+                finishSmoothScrolling();
+                if (mOverScroll != 0) {
+                    mOverScroll = 0;
+                    finishEdgeGlows();
+                    invalidate();
+                }
+            }
+
             if (touchMode == TOUCH_MODE_OFF) {
                 // Remember the last selected element
                 mResurrectToPosition = mSelectedPosition;
             }
         } else {
             // If we changed touch mode since the last time we had focus
             if (touchMode != mLastTouchMode && mLastTouchMode != TOUCH_MODE_UNKNOWN) {
                 // If we come back in trackball mode, we bring the selection back
@@ -1258,32 +1276,33 @@ public class TwoWayView extends AdapterV
 
         final int action = ev.getAction() & MotionEventCompat.ACTION_MASK;
         switch (action) {
         case MotionEvent.ACTION_DOWN:
             initOrResetVelocityTracker();
             mVelocityTracker.addMovement(ev);
 
             mScroller.abortAnimation();
+            if (mPositionScroller != null) {
+                mPositionScroller.stop();
+            }
 
             final float x = ev.getX();
             final float y = ev.getY();
 
             mLastTouchPos = (mIsVertical ? y : x);
 
             final int motionPosition = findMotionRowOrColumn((int) mLastTouchPos);
 
             mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
             mTouchRemainderPos = 0;
 
             if (mTouchMode == TOUCH_MODE_FLINGING) {
                 return true;
-            }
-
-            if (motionPosition >= 0) {
+            } else if (motionPosition >= 0) {
                 mMotionPosition = motionPosition;
                 mTouchMode = TOUCH_MODE_DOWN;
             }
 
             break;
 
         case MotionEvent.ACTION_MOVE: {
             if (mTouchMode != TOUCH_MODE_DOWN) {
@@ -1353,16 +1372,19 @@ public class TwoWayView extends AdapterV
         switch (action) {
         case MotionEvent.ACTION_DOWN: {
             if (mDataChanged) {
                 break;
             }
 
             mVelocityTracker.clear();
             mScroller.abortAnimation();
+            if (mPositionScroller != null) {
+                mPositionScroller.stop();
+            }
 
             final float x = ev.getX();
             final float y = ev.getY();
 
             mLastTouchPos = (mIsVertical ? y : x);
 
             int motionPosition = pointToPosition((int) x, (int) y);
 
@@ -1372,20 +1394,17 @@ public class TwoWayView extends AdapterV
             if (mDataChanged) {
                 break;
             }
 
             if (mTouchMode == TOUCH_MODE_FLINGING) {
                 mTouchMode = TOUCH_MODE_DRAGGING;
                 reportScrollStateChange(OnScrollListener.SCROLL_STATE_TOUCH_SCROLL);
                 motionPosition = findMotionRowOrColumn((int) mLastTouchPos);
-                return true;
-            }
-
-            if (mMotionPosition >= 0 && mAdapter.isEnabled(mMotionPosition)) {
+            } else if (mMotionPosition >= 0 && mAdapter.isEnabled(mMotionPosition)) {
                 mTouchMode = TOUCH_MODE_DOWN;
                 triggerCheckForTap();
             }
 
             mMotionPosition = motionPosition;
 
             break;
         }
@@ -1500,17 +1519,17 @@ public class TwoWayView extends AdapterV
                             mTouchMode = TOUCH_MODE_TAP;
 
                             setPressed(true);
                             positionSelector(mMotionPosition, child);
                             child.setPressed(true);
 
                             if (mSelector != null) {
                                 Drawable d = mSelector.getCurrent();
-                                if (d instanceof TransitionDrawable) {
+                                if (d != null && d instanceof TransitionDrawable) {
                                     ((TransitionDrawable) d).resetTransition();
                                 }
                             }
 
                             if (mTouchModeReset != null) {
                                 removeCallbacks(mTouchModeReset);
                             }
 
@@ -1537,16 +1556,18 @@ public class TwoWayView extends AdapterV
                             updateSelectorState();
                         }
                     } else if (!mDataChanged && mAdapter.isEnabled(motionPosition)) {
                         performClick.run();
                     }
                 }
 
                 mTouchMode = TOUCH_MODE_REST;
+
+                finishSmoothScrolling();
                 updateSelectorState();
 
                 break;
             }
 
             case TOUCH_MODE_DRAGGING:
                 if (contentFits()) {
                     mTouchMode = TOUCH_MODE_REST;
@@ -1625,16 +1646,17 @@ public class TwoWayView extends AdapterV
             if (getWidth() > 0 && getHeight() > 0 && getChildCount() > 0) {
                 layoutChildren();
             }
 
             updateSelectorState();
         } else {
             final int touchMode = mTouchMode;
             if (touchMode == TOUCH_MODE_OVERSCROLL) {
+                finishSmoothScrolling();
                 if (mOverScroll != 0) {
                     mOverScroll = 0;
                     finishEdgeGlows();
                     invalidate();
                 }
             }
         }
     }
@@ -1706,40 +1728,26 @@ public class TwoWayView extends AdapterV
     public boolean performAccessibilityAction(int action, Bundle arguments) {
         if (super.performAccessibilityAction(action, arguments)) {
             return true;
         }
 
         switch (action) {
         case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD:
             if (isEnabled() && getLastVisiblePosition() < getCount() - 1) {
-                final int viewportSize;
-                if (mIsVertical) {
-                    viewportSize = getHeight() - getPaddingTop() - getPaddingBottom();
-                } else {
-                    viewportSize = getWidth() - getPaddingLeft() - getPaddingRight();
-                }
-
                 // TODO: Use some form of smooth scroll instead
-                scrollListItemsBy(viewportSize);
+                scrollListItemsBy(getAvailableSize());
                 return true;
             }
             return false;
 
         case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD:
             if (isEnabled() && mFirstPosition > 0) {
-                final int viewportSize;
-                if (mIsVertical) {
-                    viewportSize = getHeight() - getPaddingTop() - getPaddingBottom();
-                } else {
-                    viewportSize = getWidth() - getPaddingLeft() - getPaddingRight();
-                }
-
                 // TODO: Use some form of smooth scroll instead
-                scrollListItemsBy(-viewportSize);
+                scrollListItemsBy(-getAvailableSize());
                 return true;
             }
             return false;
         }
 
         return false;
     }
 
@@ -2020,30 +2028,30 @@ public class TwoWayView extends AdapterV
      * Re-measure a child, and if its height changes, lay it out preserving its
      * top, and adjust the children below it appropriately.
      *
      * @param child The child
      * @param childIndex The view group index of the child.
      * @param numChildren The number of children in the view group.
      */
     private void measureAndAdjustDown(View child, int childIndex, int numChildren) {
-        int oldHeight = child.getHeight();
+        int oldSize = getChildSize(child);
         measureChild(child);
 
-        if (child.getMeasuredHeight() == oldHeight) {
+        if (getChildMeasuredSize(child) == oldSize) {
             return;
         }
 
         // lay out the view, preserving its top
         relayoutMeasuredChild(child);
 
         // adjust views below appropriately
-        final int heightDelta = child.getMeasuredHeight() - oldHeight;
+        final int sizeDelta = getChildMeasuredSize(child) - oldSize;
         for (int i = childIndex + 1; i < numChildren; i++) {
-            getChildAt(i).offsetTopAndBottom(heightDelta);
+            getChildAt(i).offsetTopAndBottom(sizeDelta);
         }
     }
 
     /**
      * Do an arrow scroll based on focus searching.  If a new view is
      * given focus, return the selection delta and amount to scroll via
      * an {@link ArrowScrollFocusResult}, otherwise, return null.
      *
@@ -2137,17 +2145,17 @@ public class TwoWayView extends AdapterV
         return null;
     }
 
     /**
      * @return The maximum amount a list view will scroll in response to
      *   an arrow event.
      */
     public int getMaxScrollAmount() {
-        return (int) (MAX_SCROLL_FACTOR * getHeight());
+        return (int) (MAX_SCROLL_FACTOR * getSize());
     }
 
     /**
      * @return The amount to preview next items when arrow scrolling.
      */
     private int getArrowScrollPreviewLength() {
         // FIXME: TwoWayView has no fading edge support just yet but using it
         // makes it convenient for defining the next item's previous length.
@@ -2749,17 +2757,17 @@ public class TwoWayView extends AdapterV
             }
         }
 
         final int overscrollMode = ViewCompat.getOverScrollMode(this);
         if (overscrollMode == ViewCompat.OVER_SCROLL_ALWAYS ||
                 (overscrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS && !contentFits())) {
             mTouchMode = TOUCH_MODE_OVERSCROLL;
 
-            float pull = (float) overscroll / (mIsVertical ? getHeight() : getWidth());
+            float pull = (float) overscroll / getSize();
             if (delta > 0) {
                 mStartEdge.onPull(pull);
 
                 if (!mEndEdge.isFinished()) {
                     mEndEdge.onRelease();
                 }
             } else if (delta < 0) {
                 mEndEdge.onPull(pull);
@@ -2917,24 +2925,44 @@ public class TwoWayView extends AdapterV
     private int getEndEdge() {
         if (mIsVertical) {
             return (getHeight() - getPaddingBottom());
         } else {
             return (getWidth() - getPaddingRight());
         }
     }
 
+    private int getSize() {
+        return (mIsVertical ? getHeight() : getWidth());
+    }
+
+    private int getAvailableSize() {
+        if (mIsVertical) {
+            return getHeight() - getPaddingBottom() - getPaddingTop();
+        } else {
+            return getWidth() - getPaddingRight() - getPaddingLeft();
+        }
+    }
+
     private int getChildStartEdge(View child) {
         return (mIsVertical ? child.getTop() : child.getLeft());
     }
 
     private int getChildEndEdge(View child) {
         return (mIsVertical ? child.getBottom() : child.getRight());
     }
 
+    private int getChildSize(View child) {
+        return (mIsVertical ? child.getHeight() : child.getWidth());
+    }
+
+    private int getChildMeasuredSize(View child) {
+        return (mIsVertical ? child.getMeasuredHeight() : child.getMeasuredWidth());
+    }
+
     private boolean contentFits() {
         final int childCount = getChildCount();
         if (childCount == 0) {
             return true;
         }
 
         if (childCount != mItemCount) {
             return false;
@@ -2958,17 +2986,17 @@ public class TwoWayView extends AdapterV
     private void cancelCheckForTap() {
         if (mPendingCheckForTap == null) {
             return;
         }
 
         removeCallbacks(mPendingCheckForTap);
     }
 
-    void triggerCheckForLongPress() {
+    private void triggerCheckForLongPress() {
         if (mPendingCheckForLongPress == null) {
             mPendingCheckForLongPress = new CheckForLongPress();
         }
 
         mPendingCheckForLongPress.rememberWindowAttachCount();
 
         postDelayed(mPendingCheckForLongPress,
                 ViewConfiguration.getLongPressTimeout());
@@ -2987,32 +3015,25 @@ public class TwoWayView extends AdapterV
         if (childCount == 0) {
             return true;
         }
 
         final int firstStart = getChildStartEdge(getChildAt(0));
         final int lastEnd = getChildEndEdge(getChildAt(childCount - 1));
 
         final int paddingTop = getPaddingTop();
-        final int paddingBottom = getPaddingBottom();
         final int paddingLeft = getPaddingLeft();
-        final int paddingRight = getPaddingRight();
 
         final int paddingStart = (mIsVertical ? paddingTop : paddingLeft);
 
         final int spaceBefore = paddingStart - firstStart;
         final int end = getEndEdge();
         final int spaceAfter = lastEnd - end;
 
-        final int size;
-        if (mIsVertical) {
-            size = getHeight() - paddingBottom - paddingTop;
-        } else {
-            size = getWidth() - paddingRight - paddingLeft;
-        }
+        final int size = getAvailableSize();
 
         if (incrementalDelta < 0) {
             incrementalDelta = Math.max(-(size - 1), incrementalDelta);
         } else {
             incrementalDelta = Math.min(size - 1, incrementalDelta);
         }
 
         final int firstPosition = mFirstPosition;
@@ -3154,17 +3175,17 @@ public class TwoWayView extends AdapterV
                     boolean needsInvalidate =
                             edge.onAbsorb(Math.abs((int) getCurrVelocity()));
 
                     if (needsInvalidate) {
                         ViewCompat.postInvalidateOnAnimation(this);
                     }
                 }
 
-                mScroller.abortAnimation();
+                finishSmoothScrolling();
             }
 
             mTouchMode = TOUCH_MODE_REST;
             reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
         }
     }
 
     private void finishEdgeGlows() {
@@ -3214,16 +3235,26 @@ public class TwoWayView extends AdapterV
             canvas.rotate(90);
         }
 
         final boolean needsInvalidate = mEndEdge.draw(canvas);
         canvas.restoreToCount(restoreCount);
         return needsInvalidate;
     }
 
+    private void finishSmoothScrolling() {
+        mTouchMode = TOUCH_MODE_REST;
+        reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
+
+        mScroller.abortAnimation();
+        if (mPositionScroller != null) {
+            mPositionScroller.stop();
+        }
+    }
+
     private void drawSelector(Canvas canvas) {
         if (!mSelectorRect.isEmpty()) {
             final Drawable selector = mSelector;
             selector.setBounds(mSelectorRect);
             selector.draw(canvas);
         }
     }
 
@@ -3231,17 +3262,17 @@ public class TwoWayView extends AdapterV
         setSelector(getResources().getDrawable(
                 android.R.drawable.list_selector_background));
     }
 
     private boolean shouldShowSelector() {
         return (hasFocus() && !isInTouchMode()) || touchModeDrawsInPressedState();
     }
 
-    void positionSelector(int position, View selected) {
+    private void positionSelector(int position, View selected) {
         if (position != INVALID_POSITION) {
             mSelectorPosition = position;
         }
 
         mSelectorRect.set(selected.getLeft(), selected.getTop(), selected.getRight(),
                 selected.getBottom());
 
         final boolean isChildViewEnabled = mIsChildViewEnabled;
@@ -3341,17 +3372,17 @@ public class TwoWayView extends AdapterV
 
                 child.setPressed(true);
             }
 
             setPressed(true);
 
             final boolean longClickable = isLongClickable();
             final Drawable d = selector.getCurrent();
-            if (d instanceof TransitionDrawable) {
+            if (d != null && d instanceof TransitionDrawable) {
                 if (longClickable) {
                     ((TransitionDrawable) d).startTransition(
                             ViewConfiguration.getLongPressTimeout());
                 } else {
                     ((TransitionDrawable) d).resetTransition();
                 }
             }
 
@@ -3401,33 +3432,33 @@ public class TwoWayView extends AdapterV
 
             post(mSelectionNotifier);
         } else {
             fireOnSelected();
             performAccessibilityActionsOnSelected();
         }
     }
 
-    void fireOnSelected() {
+    private void fireOnSelected() {
         OnItemSelectedListener listener = getOnItemSelectedListener();
         if (listener == null) {
             return;
         }
 
         final int selection = getSelectedItemPosition();
         if (selection >= 0) {
             View v = getSelectedView();
             listener.onItemSelected(this, v, selection,
                     mAdapter.getItemId(selection));
         } else {
             listener.onNothingSelected(this);
         }
     }
 
-    void performAccessibilityActionsOnSelected() {
+    private void performAccessibilityActionsOnSelected() {
         final int position = getSelectedItemPosition();
         if (position >= 0) {
             // We fire selection events here not in View
             sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
         }
     }
 
     private int lookForSelectablePosition(int position) {
@@ -3656,17 +3687,117 @@ public class TwoWayView extends AdapterV
                 mSyncRowId = mAdapter.getItemId(position);
             }
 
             requestLayout();
         }
     }
 
     public void scrollBy(int offset) {
-        scrollListItemsBy(offset);
+        scrollListItemsBy(-offset);
+    }
+
+    /**
+     * Smoothly scroll to the specified adapter position. The view will
+     * scroll such that the indicated position is displayed.
+     * @param position Scroll to this adapter position.
+     */
+    public void smoothScrollToPosition(int position) {
+        if (mPositionScroller == null) {
+            mPositionScroller = new PositionScroller();
+        }
+        mPositionScroller.start(position);
+    }
+
+    /**
+     * Smoothly scroll to the specified adapter position. The view will scroll
+     * such that the indicated position is displayed <code>offset</code> pixels from
+     * the top/left edge of the view, according to the orientation. If this is
+     * impossible, (e.g. the offset would scroll the first or last item beyond the boundaries
+     * of the list) it will get as close as possible. The scroll will take
+     * <code>duration</code> milliseconds to complete.
+     *
+     * @param position Position to scroll to
+     * @param offset Desired distance in pixels of <code>position</code> from the top/left
+     *               of the view when scrolling is finished
+     * @param duration Number of milliseconds to use for the scroll
+     */
+    public void smoothScrollToPositionFromOffset(int position, int offset, int duration) {
+        if (mPositionScroller == null) {
+            mPositionScroller = new PositionScroller();
+        }
+        mPositionScroller.startWithOffset(position, offset, duration);
+    }
+
+    /**
+     * Smoothly scroll to the specified adapter position. The view will scroll
+     * such that the indicated position is displayed <code>offset</code> pixels from
+     * the top edge of the view. If this is impossible, (e.g. the offset would scroll
+     * the first or last item beyond the boundaries of the list) it will get as close
+     * as possible.
+     *
+     * @param position Position to scroll to
+     * @param offset Desired distance in pixels of <code>position</code> from the top
+     *               of the view when scrolling is finished
+     */
+    public void smoothScrollToPositionFromOffset(int position, int offset) {
+        if (mPositionScroller == null) {
+            mPositionScroller = new PositionScroller();
+        }
+        mPositionScroller.startWithOffset(position, offset);
+    }
+
+    /**
+     * Smoothly scroll to the specified adapter position. The view will
+     * scroll such that the indicated position is displayed, but it will
+     * stop early if scrolling further would scroll boundPosition out of
+     * view.
+     *
+     * @param position Scroll to this adapter position.
+     * @param boundPosition Do not scroll if it would move this adapter
+     *          position out of view.
+     */
+    public void smoothScrollToPosition(int position, int boundPosition) {
+        if (mPositionScroller == null) {
+            mPositionScroller = new PositionScroller();
+        }
+        mPositionScroller.start(position, boundPosition);
+    }
+
+    /**
+     * Smoothly scroll by distance pixels over duration milliseconds.
+     * @param distance Distance to scroll in pixels.
+     * @param duration Duration of the scroll animation in milliseconds.
+     */
+    public void smoothScrollBy(int distance, int duration) {
+        // No sense starting to scroll if we're not going anywhere
+        final int firstPosition = mFirstPosition;
+        final int childCount = getChildCount();
+        final int lastPosition = firstPosition + childCount;
+        final int start = getStartEdge();
+        final int end = getEndEdge();
+
+        if (distance == 0 || mItemCount == 0 || childCount == 0 ||
+                (firstPosition == 0 && getChildStartEdge(getChildAt(0)) == start && distance < 0) ||
+                (lastPosition == mItemCount &&
+                            getChildEndEdge(getChildAt(childCount - 1)) == end && distance > 0)) {
+            finishSmoothScrolling();
+        } else {
+            mScroller.startScroll(0, 0,
+                                  mIsVertical ? 0 : -distance,
+                                  mIsVertical ? -distance : 0,
+                                  duration);
+
+            mLastTouchPos = 0;
+
+            mTouchMode = TOUCH_MODE_FLINGING;
+            reportScrollStateChange(OnScrollListener.SCROLL_STATE_FLING);
+
+            ViewCompat.postInvalidateOnAnimation(this);
+        }
     }
 
     @Override
     public boolean dispatchKeyEvent(KeyEvent event) {
         // Dispatch in the normal way
         boolean handled = super.dispatchKeyEvent(event);
         if (!handled) {
             // If we didn't handle it...
@@ -3770,17 +3901,17 @@ public class TwoWayView extends AdapterV
                 mEndEdge.setSize(width, height);
             } else {
                 mStartEdge.setSize(height, width);
                 mEndEdge.setSize(height, width);
             }
         }
     }
 
-    void layoutChildren() {
+    private void layoutChildren() {
         if (getWidth() == 0 || getHeight() == 0) {
             return;
         }
 
         final boolean blockLayoutRequests = mBlockLayoutRequests;
         if (!blockLayoutRequests) {
             mBlockLayoutRequests = true;
         } else {
@@ -3848,19 +3979,17 @@ public class TwoWayView extends AdapterV
                 handleDataChanged();
             }
 
             // Handle the empty set by removing all views that are visible
             // and calling it a day
             if (mItemCount == 0) {
                 resetState();
                 return;
-            }
-
-            if (mItemCount != mAdapter.getCount()) {
+            } else if (mItemCount != mAdapter.getCount()) {
                 throw new IllegalStateException("The content of the adapter has changed but "
                         + "TwoWayView 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 TwoWayView(" + getId() + ", " + getClass()
                         + ") with Adapter(" + mAdapter.getClass() + ")]");
             }
 
             setSelectedPositionInt(mNextSelectedPosition);
@@ -4275,40 +4404,41 @@ public class TwoWayView extends AdapterV
                         // We saved our state when not in touch mode. (We know this because
                         // mSyncMode is SYNC_SELECTED_POSITION.) Now we are trying to
                         // restore in touch mode. Just leave mSyncPosition as it is (possibly
                         // adjusting if the available range changed) and return.
                         mLayoutMode = LAYOUT_SYNC;
                         mSyncPosition = Math.min(Math.max(0, mSyncPosition), itemCount - 1);
 
                         return;
-                    }
-                    // See if we can find a position in the new data with the same
-                    // id as the old selection. This will change mSyncPosition.
-                    newPos = findSyncPosition();
-                    if (newPos >= 0) {
-                        // Found it. Now verify that new selection is still selectable
-                        selectablePos = lookForSelectablePosition(newPos, true);
-                        if (selectablePos == newPos) {
-                            // Same row id is selected
-                            mSyncPosition = newPos;
-
-                            if (mSyncHeight == getHeight()) {
-                                // If we are at the same height as when we saved state, try
-                                // to restore the scroll position too.
-                                mLayoutMode = LAYOUT_SYNC;
-                            } else {
-                                // We are not the same height as when the selection was saved, so
-                                // don't try to restore the exact position
-                                mLayoutMode = LAYOUT_SET_SELECTION;
+                    } else {
+                        // See if we can find a position in the new data with the same
+                        // id as the old selection. This will change mSyncPosition.
+                        newPos = findSyncPosition();
+                        if (newPos >= 0) {
+                            // Found it. Now verify that new selection is still selectable
+                            selectablePos = lookForSelectablePosition(newPos, true);
+                            if (selectablePos == newPos) {
+                                // Same row id is selected
+                                mSyncPosition = newPos;
+
+                                if (mSyncSize == getSize()) {
+                                    // If we are at the same height as when we saved state, try
+                                    // to restore the scroll position too.
+                                    mLayoutMode = LAYOUT_SYNC;
+                                } else {
+                                    // We are not the same height as when the selection was saved, so
+                                    // don't try to restore the exact position
+                                    mLayoutMode = LAYOUT_SET_SELECTION;
+                                }
+
+                                // Restore selection
+                                setNextSelectedPositionInt(newPos);
+                                return;
                             }
-
-                            // Restore selection
-                            setNextSelectedPositionInt(newPos);
-                            return;
                         }
                     }
                     break;
 
                 case SYNC_FIRST_POSITION:
                     // Leave mSyncPosition as it is -- just pin to available range
                     mLayoutMode = LAYOUT_SYNC;
                     mSyncPosition = Math.min(Math.max(0, mSyncPosition), itemCount - 1);
@@ -4434,16 +4564,19 @@ public class TwoWayView extends AdapterV
                     selectedPosition = firstPosition + i;
                     selectedStart = childStart;
                     break;
                 }
             }
         }
 
         mResurrectToPosition = INVALID_POSITION;
+
+        finishSmoothScrolling();
+
         mTouchMode = TOUCH_MODE_REST;
         reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
 
         mSpecificStart = selectedStart;
 
         selectedPosition = lookForSelectablePosition(selectedPosition, down);
         if (selectedPosition >= firstPosition && selectedPosition <= getLastVisiblePosition()) {
             mLayoutMode = LAYOUT_SPECIFIC;
@@ -4721,21 +4854,21 @@ public class TwoWayView extends AdapterV
 
     private View makeAndAddView(int position, int offset, boolean flow, boolean selected) {
         final int top;
         final int left;
 
         // Compensate item margin on the first item of the list if the item margin
         // is negative to avoid incorrect offset for the very first child.
         if (mIsVertical) {
-            top = offset - (mItemMargin < 0 && position == 0 && !flow ? mItemMargin : 0);
+            top = offset;
             left = getPaddingLeft();
         } else {
             top = getPaddingTop();
-            left = offset - (mItemMargin < 0 && position == 0 && !flow ? mItemMargin: 0);
+            left = offset;
         }
 
         if (!mDataChanged) {
             // Try to use an existing view for this position
             final View activeChild = mRecycler.getActiveView(position);
             if (activeChild != null) {
                 // Found it -- we're using an existing child
                 // This just needs to be positioned
@@ -4885,17 +5018,17 @@ public class TwoWayView extends AdapterV
 
     private View fillSpecific(int position, int offset) {
         final boolean tempIsSelected = (position == mSelectedPosition);
         View temp = makeAndAddView(position, offset, true, tempIsSelected);
 
         // Possibly changed again in fillBefore if we add rows above this one.
         mFirstPosition = position;
 
-        final int offsetBefore = getChildStartEdge(temp) + mItemMargin;
+        final int offsetBefore = getChildStartEdge(temp) - mItemMargin;
         final View before = fillBefore(position - 1, offsetBefore);
 
         // This will correct for the top of the first view not touching the top of the list
         adjustViewsStartOrEnd();
 
         final int offsetAfter = getChildEndEdge(temp) + mItemMargin;
         final View after = fillAfter(position + 1, offsetAfter);
 
@@ -5293,17 +5426,17 @@ public class TwoWayView extends AdapterV
         setNextSelectedPositionInt(INVALID_POSITION);
 
         mSelectorPosition = INVALID_POSITION;
         mSelectorRect.setEmpty();
 
         invalidate();
     }
 
-    void rememberSyncState() {
+    private void rememberSyncState() {
         if (getChildCount() == 0) {
             return;
         }
 
         mNeedSync = true;
 
         if (mSelectedPosition >= 0) {
             View child = getChildAt(mSelectedPosition - mFirstPosition);
@@ -5402,17 +5535,17 @@ public class TwoWayView extends AdapterV
 
         if (checkedStateChanged) {
             updateOnScreenCheckedViews();
         }
 
         return super.performItemClick(view, position, id);
     }
 
-    boolean performLongPress(final View child,
+    private boolean performLongPress(final View child,
             final int longPressPosition, final long longPressId) {
         // CHOICE_MODE_MULTIPLE_MODAL takes over long press.
         boolean handled = false;
 
         OnItemLongClickListener listener = getOnItemLongClickListener();
         if (listener != null) {
             handled = listener.onItemLongClick(TwoWayView.this, child,
                     longPressPosition, longPressId);
@@ -5464,25 +5597,25 @@ public class TwoWayView extends AdapterV
         Parcelable superState = super.onSaveInstanceState();
         SavedState ss = new SavedState(superState);
 
         if (mPendingSync != null) {
             ss.selectedId = mPendingSync.selectedId;
             ss.firstId = mPendingSync.firstId;
             ss.viewStart = mPendingSync.viewStart;
             ss.position = mPendingSync.position;
-            ss.height = mPendingSync.height;
+            ss.size = mPendingSync.size;
 
             return ss;
         }
 
         boolean haveChildren = (getChildCount() > 0 && mItemCount > 0);
         long selectedId = getSelectedItemId();
         ss.selectedId = selectedId;
-        ss.height = getHeight();
+        ss.size = getSize();
 
         if (selectedId >= 0) {
             ss.viewStart = mSelectedStart;
             ss.position = getSelectedItemPosition();
             ss.firstId = INVALID_POSITION;
         } else if (haveChildren && mFirstPosition > 0) {
             // Remember the position of the first child.
             // We only do this if we are not currently at the top of
@@ -5532,17 +5665,17 @@ public class TwoWayView extends AdapterV
     }
 
     @Override
     public void onRestoreInstanceState(Parcelable state) {
         SavedState ss = (SavedState) state;
         super.onRestoreInstanceState(ss.getSuperState());
 
         mDataChanged = true;
-        mSyncHeight = ss.height;
+        mSyncSize = ss.size;
 
         if (ss.selectedId >= 0) {
             mNeedSync = true;
             mPendingSync = ss;
             mSyncRowId = ss.selectedId;
             mSyncPosition = ss.position;
             mSpecificStart = ss.viewStart;
             mSyncMode = SYNC_SELECTED_POSITION;
@@ -5653,17 +5786,17 @@ public class TwoWayView extends AdapterV
                         "does not make much sense as the view might change orientation. " +
                         "Falling back to WRAP_CONTENT");
                 this.height = WRAP_CONTENT;
             }
         }
     }
 
     class RecycleBin {
-        RecyclerListener mRecyclerListener;
+        private RecyclerListener mRecyclerListener;
         private int mFirstActivePosition;
         private View[] mActiveViews = new View[0];
         private ArrayList<View>[] mScrapViews;
         private int mViewTypeCount;
         private ArrayList<View> mCurrentScrap;
         private SparseArrayCompat<View> mTransientStateViews;
 
         public void setViewTypeCount(int viewTypeCount) {
@@ -5787,20 +5920,21 @@ public class TwoWayView extends AdapterV
             if (mTransientStateViews != null) {
                 mTransientStateViews.clear();
             }
         }
 
         View getScrapView(int position) {
             if (mViewTypeCount == 1) {
                 return retrieveFromScrap(mCurrentScrap, position);
-            }
-            int whichScrap = mAdapter.getItemViewType(position);
-            if (whichScrap >= 0 && whichScrap < mScrapViews.length) {
-                return retrieveFromScrap(mScrapViews[whichScrap], position);
+            } else {
+                int whichScrap = mAdapter.getItemViewType(position);
+                if (whichScrap >= 0 && whichScrap < mScrapViews.length) {
+                    return retrieveFromScrap(mScrapViews[whichScrap], position);
+                }
             }
 
             return null;
         }
 
         @TargetApi(14)
         void addScrapView(View scrap, int position) {
             LayoutParams lp = (LayoutParams) scrap.getLayoutParams();
@@ -5986,17 +6120,17 @@ public class TwoWayView extends AdapterV
         mDesiredFocusableInTouchModeState = focusable;
         if (focusable) {
             mDesiredFocusableState = true;
         }
 
         super.setFocusableInTouchMode(focusable && !empty);
     }
 
-    void checkFocus() {
+    private void checkFocus() {
         final ListAdapter adapter = getAdapter();
         final boolean focusable = (adapter != null && adapter.getCount() > 0);
 
         // The order in which we set focusable in touch mode/focusable may matter
         // for the client, see View.setFocusableInTouchMode() comments for more
         // details
         super.setFocusableInTouchMode(focusable && mDesiredFocusableInTouchModeState);
         super.setFocusable(focusable && mDesiredFocusableState);
@@ -6030,17 +6164,17 @@ public class TwoWayView extends AdapterV
                 mEmptyView.setVisibility(View.GONE);
             }
 
             setVisibility(View.VISIBLE);
         }
     }
 
     private class AdapterDataSetObserver extends DataSetObserver {
-        private Parcelable mInstanceState;
+        private Parcelable mInstanceState = null;
 
         @Override
         public void onChanged() {
             mDataChanged = true;
             mOldItemCount = mItemCount;
             mItemCount = getAdapter().getCount();
 
             // Detect the case where a cursor that was previously invalidated has
@@ -6084,17 +6218,17 @@ public class TwoWayView extends AdapterV
         }
     }
 
     static class SavedState extends BaseSavedState {
         long selectedId;
         long firstId;
         int viewStart;
         int position;
-        int height;
+        int size;
         int checkedItemCount;
         SparseBooleanArray checkState;
         LongSparseArray<Integer> checkIdState;
 
         /**
          * Constructor called from {@link TwoWayView#onSaveInstanceState()}
          */
         SavedState(Parcelable superState) {
@@ -6106,17 +6240,17 @@ public class TwoWayView extends AdapterV
          */
         private SavedState(Parcel in) {
             super(in);
 
             selectedId = in.readLong();
             firstId = in.readLong();
             viewStart = in.readInt();
             position = in.readInt();
-            height = in.readInt();
+            size = in.readInt();
 
             checkedItemCount = in.readInt();
             checkState = in.readSparseBooleanArray();
 
             final int N = in.readInt();
             if (N > 0) {
                 checkIdState = new LongSparseArray<Integer>();
                 for (int i = 0; i < N; i++) {
@@ -6130,17 +6264,17 @@ public class TwoWayView extends AdapterV
         @Override
         public void writeToParcel(Parcel out, int flags) {
             super.writeToParcel(out, flags);
 
             out.writeLong(selectedId);
             out.writeLong(firstId);
             out.writeInt(viewStart);
             out.writeInt(position);
-            out.writeInt(height);
+            out.writeInt(size);
 
             out.writeInt(checkedItemCount);
             out.writeSparseBooleanArray(checkState);
 
             final int N = checkIdState != null ? checkIdState.size() : 0;
             out.writeInt(N);
 
             for (int i = 0; i < N; i++) {
@@ -6151,17 +6285,17 @@ public class TwoWayView extends AdapterV
 
         @Override
         public String toString() {
             return "TwoWayView.SavedState{"
                     + Integer.toHexString(System.identityHashCode(this))
                     + " selectedId=" + selectedId
                     + " firstId=" + firstId
                     + " viewStart=" + viewStart
-                    + " height=" + height
+                    + " size=" + size
                     + " position=" + position
                     + " checkState=" + checkState + "}";
         }
 
         public static final Parcelable.Creator<SavedState> CREATOR
                 = new Parcelable.Creator<SavedState>() {
             @Override
             public SavedState createFromParcel(Parcel in) {
@@ -6252,17 +6386,17 @@ public class TwoWayView extends AdapterV
                     positionSelector(mMotionPosition, child);
                     refreshDrawableState();
 
                     final boolean longClickable = isLongClickable();
 
                     if (mSelector != null) {
                         Drawable d = mSelector.getCurrent();
 
-                        if (d instanceof TransitionDrawable) {
+                        if (d != null && d instanceof TransitionDrawable) {
                             if (longClickable) {
                                 final int longPressTimeout = ViewConfiguration.getLongPressTimeout();
                                 ((TransitionDrawable) d).startTransition(longPressTimeout);
                             } else {
                                 ((TransitionDrawable) d).resetTransition();
                             }
                         }
                     }
@@ -6300,17 +6434,16 @@ public class TwoWayView extends AdapterV
                 } else {
                     mTouchMode = TOUCH_MODE_DONE_WAITING;
                 }
             }
         }
     }
 
     private class CheckForKeyLongPress extends WindowRunnable implements Runnable {
-        @Override
         public void run() {
             if (!isPressed() || mSelectedPosition < 0) {
                 return;
             }
 
             final int index = mSelectedPosition - mFirstPosition;
             final View v = getChildAt(index);
 
@@ -6331,17 +6464,17 @@ public class TwoWayView extends AdapterV
                 if (v != null) {
                     v.setPressed(false);
                 }
             }
         }
     }
 
     private static class ArrowScrollFocusResult {
-        int mSelectedPosition;
+        private int mSelectedPosition;
         private int mAmountToScroll;
 
         /**
          * How {@link TwoWayView#arrowScrollFocused} returns its values.
          */
         void populate(int selectedPosition, int amountToScroll) {
             mSelectedPosition = selectedPosition;
             mAmountToScroll = amountToScroll;
@@ -6433,9 +6566,470 @@ public class TwoWayView extends AdapterV
 
             case AccessibilityNodeInfoCompat.ACTION_LONG_CLICK:
                 return isLongClickable() && performLongPress(host, position, id);
             }
 
             return false;
         }
     }
+
+    private class PositionScroller implements Runnable {
+        private static final int SCROLL_DURATION = 200;
+
+        private static final int MOVE_AFTER_POS = 1;
+        private static final int MOVE_BEFORE_POS = 2;
+        private static final int MOVE_AFTER_BOUND = 3;
+        private static final int MOVE_BEFORE_BOUND = 4;
+        private static final int MOVE_OFFSET = 5;
+
+        private int mMode;
+        private int mTargetPosition;
+        private int mBoundPosition;
+        private int mLastSeenPosition;
+        private int mScrollDuration;
+        private final int mExtraScroll;
+
+        private int mOffsetFromStart;
+
+        PositionScroller() {
+            mExtraScroll = ViewConfiguration.get(mContext).getScaledFadingEdgeLength();
+        }
+
+        void start(final int position) {
+            stop();
+
+            if (mDataChanged) {
+                // Wait until we're back in a stable state to try this.
+                mPositionScrollAfterLayout = new Runnable() {
+                    @Override public void run() {
+                        start(position);
+                    }
+                };
+
+                return;
+            }
+
+            final int childCount = getChildCount();
+            if (childCount == 0) {
+                // Can't scroll without children.
+                return;
+            }
+
+            final int firstPosition = mFirstPosition;
+            final int lastPosition = firstPosition + childCount - 1;
+
+            final int clampedPosition = Math.max(0, Math.min(getCount() - 1, position));
+
+            final int viewTravelCount;
+            if (clampedPosition < firstPosition) {
+                viewTravelCount = firstPosition - clampedPosition + 1;
+                mMode = MOVE_BEFORE_POS;
+            } else if (clampedPosition > lastPosition) {
+                viewTravelCount = clampedPosition - lastPosition + 1;
+                mMode = MOVE_AFTER_POS;
+            } else {
+                scrollToVisible(clampedPosition, INVALID_POSITION, SCROLL_DURATION);
+                return;
+            }
+
+            if (viewTravelCount > 0) {
+                mScrollDuration = SCROLL_DURATION / viewTravelCount;
+            } else {
+                mScrollDuration = SCROLL_DURATION;
+            }
+
+            mTargetPosition = clampedPosition;
+            mBoundPosition = INVALID_POSITION;
+            mLastSeenPosition = INVALID_POSITION;
+
+            ViewCompat.postOnAnimation(TwoWayView.this, this);
+        }
+
+        void start(final int position, final int boundPosition) {
+            stop();
+
+            if (boundPosition == INVALID_POSITION) {
+                start(position);
+                return;
+            }
+
+            if (mDataChanged) {
+                // Wait until we're back in a stable state to try this.
+                mPositionScrollAfterLayout = new Runnable() {
+                    @Override public void run() {
+                        start(position, boundPosition);
+                    }
+                };
+
+                return;
+            }
+
+            final int childCount = getChildCount();
+            if (childCount == 0) {
+                // Can't scroll without children.
+                return;
+            }
+
+            final int firstPosition = mFirstPosition;
+            final int lastPosition = firstPosition + childCount - 1;
+
+            final int clampedPosition = Math.max(0, Math.min(getCount() - 1, position));
+
+            final int viewTravelCount;
+            if (clampedPosition < firstPosition) {
+                final int boundPositionFromLast = lastPosition - boundPosition;
+                if (boundPositionFromLast < 1) {
+                    // Moving would shift our bound position off the screen. Abort.
+                    return;
+                }
+
+                final int positionTravel = firstPosition - clampedPosition + 1;
+                final int boundTravel = boundPositionFromLast - 1;
+                if (boundTravel < positionTravel) {
+                    viewTravelCount = boundTravel;
+                    mMode = MOVE_BEFORE_BOUND;
+                } else {
+                    viewTravelCount = positionTravel;
+                    mMode = MOVE_BEFORE_POS;
+                }
+            } else if (clampedPosition > lastPosition) {
+                final int boundPositionFromFirst = boundPosition - firstPosition;
+                if (boundPositionFromFirst < 1) {
+                    // Moving would shift our bound position off the screen. Abort.
+                    return;
+                }
+
+                final int positionTravel = clampedPosition - lastPosition + 1;
+                final int boundTravel = boundPositionFromFirst - 1;
+                if (boundTravel < positionTravel) {
+                    viewTravelCount = boundTravel;
+                    mMode = MOVE_AFTER_BOUND;
+                } else {
+                    viewTravelCount = positionTravel;
+                    mMode = MOVE_AFTER_POS;
+                }
+            } else {
+                scrollToVisible(clampedPosition, boundPosition, SCROLL_DURATION);
+                return;
+            }
+
+            if (viewTravelCount > 0) {
+                mScrollDuration = SCROLL_DURATION / viewTravelCount;
+            } else {
+                mScrollDuration = SCROLL_DURATION;
+            }
+
+            mTargetPosition = clampedPosition;
+            mBoundPosition = boundPosition;
+            mLastSeenPosition = INVALID_POSITION;
+
+            ViewCompat.postOnAnimation(TwoWayView.this, this);
+        }
+
+        void startWithOffset(int position, int offset) {
+            startWithOffset(position, offset, SCROLL_DURATION);
+        }
+
+        void startWithOffset(final int position, int offset, final int duration) {
+            stop();
+
+            if (mDataChanged) {
+                // Wait until we're back in a stable state to try this.
+                final int postOffset = offset;
+                mPositionScrollAfterLayout = new Runnable() {
+                    @Override public void run() {
+                        startWithOffset(position, postOffset, duration);
+                    }
+                };
+
+                return;
+            }
+
+            final int childCount = getChildCount();
+            if (childCount == 0) {
+                // Can't scroll without children.
+                return;
+            }
+
+            offset += getStartEdge();
+
+            mTargetPosition = Math.max(0, Math.min(getCount() - 1, position));
+            mOffsetFromStart = offset;
+            mBoundPosition = INVALID_POSITION;
+            mLastSeenPosition = INVALID_POSITION;
+            mMode = MOVE_OFFSET;
+
+            final int firstPosition = mFirstPosition;
+            final int lastPosition = firstPosition + childCount - 1;
+
+            final int viewTravelCount;
+            if (mTargetPosition < firstPosition) {
+                viewTravelCount = firstPosition - mTargetPosition;
+            } else if (mTargetPosition > lastPosition) {
+                viewTravelCount = mTargetPosition - lastPosition;
+            } else {
+                // On-screen, just scroll.
+                final View targetView = getChildAt(mTargetPosition - firstPosition);
+                final int targetStart = getChildStartEdge(targetView);
+                smoothScrollBy(targetStart - offset, duration);
+                return;
+            }
+
+            // Estimate how many screens we should travel
+            final float screenTravelCount = (float) viewTravelCount / childCount;
+            mScrollDuration = screenTravelCount < 1 ?
+                    duration : (int) (duration / screenTravelCount);
+            mLastSeenPosition = INVALID_POSITION;
+
+            ViewCompat.postOnAnimation(TwoWayView.this, this);
+        }
+
+        /**
+         * Scroll such that targetPos is in the visible padded region without scrolling
+         * boundPos out of view. Assumes targetPos is onscreen.
+         */
+        void scrollToVisible(int targetPosition, int boundPosition, int duration) {
+            final int childCount = getChildCount();
+            final int firstPosition = mFirstPosition;
+            final int lastPosition = firstPosition + childCount - 1;
+
+            final int start = getStartEdge();
+            final int end = getEndEdge();
+
+            if (targetPosition < firstPosition || targetPosition > lastPosition) {
+                Log.w(LOGTAG, "scrollToVisible called with targetPosition " + targetPosition +
+                        " not visible [" + firstPosition + ", " + lastPosition + "]");
+            }
+
+            if (boundPosition < firstPosition || boundPosition > lastPosition) {
+                // boundPos doesn't matter, it's already offscreen.
+                boundPosition = INVALID_POSITION;
+            }
+
+            final View targetChild = getChildAt(targetPosition - firstPosition);
+            final int targetStart = getChildStartEdge(targetChild);
+            final int targetEnd = getChildEndEdge(targetChild);
+
+            int scrollBy = 0;
+            if (targetEnd > end) {
+                scrollBy = targetEnd - end;
+            }
+            if (targetStart < start) {
+                scrollBy = targetStart - start;
+            }
+
+            if (scrollBy == 0) {
+                return;
+            }
+
+            if (boundPosition >= 0) {
+                final View boundChild = getChildAt(boundPosition - firstPosition);
+                final int boundStart = getChildStartEdge(boundChild);
+                final int boundEnd = getChildEndEdge(boundChild);
+                final int absScroll = Math.abs(scrollBy);
+
+                if (scrollBy < 0 && boundEnd + absScroll > end) {
+                    // Don't scroll the bound view off the end of the screen.
+                    scrollBy = Math.max(0, boundEnd - end);
+                } else if (scrollBy > 0 && boundStart - absScroll < start) {
+                    // Don't scroll the bound view off the top of the screen.
+                    scrollBy = Math.min(0, boundStart - start);
+                }
+            }
+
+            smoothScrollBy(scrollBy, duration);
+        }
+
+        void stop() {
+            removeCallbacks(this);
+        }
+
+        @Override
+        public void run() {
+            final int size = getAvailableSize();
+            final int firstPosition = mFirstPosition;
+
+            final int startPadding = (mIsVertical ? getPaddingTop() : getPaddingLeft());
+            final int endPadding = (mIsVertical ? getPaddingBottom() : getPaddingRight());
+
+            switch (mMode) {
+                case MOVE_AFTER_POS: {
+                    final int lastViewIndex = getChildCount() - 1;
+                    if (lastViewIndex < 0) {
+                        return;
+                    }
+
+                    final int lastPosition = firstPosition + lastViewIndex;
+                    if (lastPosition == mLastSeenPosition) {
+                        // No new views, let things keep going.
+                        ViewCompat.postOnAnimation(TwoWayView.this, this);
+                        return;
+                    }
+
+                    final View lastView = getChildAt(lastViewIndex);
+                    final int lastViewSize = getChildSize(lastView);
+                    final int lastViewStart = getChildStartEdge(lastView);
+                    final int lastViewPixelsShowing = size - lastViewStart;
+                    final int extraScroll = lastPosition < mItemCount - 1 ?
+                            Math.max(endPadding, mExtraScroll) : endPadding;
+
+                    final int scrollBy = lastViewSize - lastViewPixelsShowing + extraScroll;
+                    smoothScrollBy(scrollBy, mScrollDuration);
+
+                    mLastSeenPosition = lastPosition;
+                    if (lastPosition < mTargetPosition) {
+                        ViewCompat.postOnAnimation(TwoWayView.this, this);
+                    }
+
+                    break;
+                }
+
+                case MOVE_AFTER_BOUND: {
+                    final int nextViewIndex = 1;
+                    final int childCount = getChildCount();
+                    if (firstPosition == mBoundPosition ||
+                        childCount <= nextViewIndex ||
+                        firstPosition + childCount >= mItemCount) {
+                        return;
+                    }
+
+                    final int nextPosition = firstPosition + nextViewIndex;
+
+                    if (nextPosition == mLastSeenPosition) {
+                        // No new views, let things keep going.
+                        ViewCompat.postOnAnimation(TwoWayView.this, this);
+                        return;
+                    }
+
+                    final View nextView = getChildAt(nextViewIndex);
+                    final int nextViewSize = getChildSize(nextView);
+                    final int nextViewStart = getChildStartEdge(nextView);
+                    final int extraScroll = Math.max(endPadding, mExtraScroll);
+                    if (nextPosition < mBoundPosition) {
+                        smoothScrollBy(Math.max(0, nextViewSize + nextViewStart - extraScroll),
+                                mScrollDuration);
+                        mLastSeenPosition = nextPosition;
+                        ViewCompat.postOnAnimation(TwoWayView.this, this);
+                    } else  {
+                        if (nextViewSize > extraScroll) {
+                            smoothScrollBy(nextViewSize - extraScroll, mScrollDuration);
+                        }
+                    }
+
+                    break;
+                }
+
+                case MOVE_BEFORE_POS: {
+                    if (firstPosition == mLastSeenPosition) {
+                        // No new views, let things keep going.
+                        ViewCompat.postOnAnimation(TwoWayView.this, this);
+                        return;
+                    }
+
+                    final View firstView = getChildAt(0);
+                    if (firstView == null) {
+                        return;
+                    }
+
+                    final int firstViewTop = getChildStartEdge(firstView);
+                    final int extraScroll = firstPosition > 0 ?
+                            Math.max(mExtraScroll, startPadding) : startPadding;
+
+                    smoothScrollBy(firstViewTop - extraScroll, mScrollDuration);
+                    mLastSeenPosition = firstPosition;
+
+                    if (firstPosition > mTargetPosition) {
+                        ViewCompat.postOnAnimation(TwoWayView.this, this);
+                    }
+
+                    break;
+                }
+
+                case MOVE_BEFORE_BOUND: {
+                    final int lastViewIndex = getChildCount() - 2;
+                    if (lastViewIndex < 0) {
+                        return;
+                    }
+
+                    final int lastPosition = firstPosition + lastViewIndex;
+
+                    if (lastPosition == mLastSeenPosition) {
+                        // No new views, let things keep going.
+                        ViewCompat.postOnAnimation(TwoWayView.this, this);
+                        return;
+                    }
+
+                    final View lastView = getChildAt(lastViewIndex);
+                    final int lastViewSize = getChildSize(lastView);
+                    final int lastViewStart = getChildStartEdge(lastView);
+                    final int lastViewPixelsShowing = size - lastViewStart;
+                    final int extraScroll = Math.max(startPadding, mExtraScroll);
+
+                    mLastSeenPosition = lastPosition;
+
+                    if (lastPosition > mBoundPosition) {
+                        smoothScrollBy(-(lastViewPixelsShowing - extraScroll), mScrollDuration);
+                        ViewCompat.postOnAnimation(TwoWayView.this, this);
+                    } else {
+                        final int end = size - extraScroll;
+                        final int lastViewEnd = lastViewStart + lastViewSize;
+                        if (end > lastViewEnd) {
+                            smoothScrollBy(-(end - lastViewEnd), mScrollDuration);
+                        }
+                    }
+
+                    break;
+                }
+
+                case MOVE_OFFSET: {
+                    if (mLastSeenPosition == firstPosition) {
+                        // No new views, let things keep going.
+                        ViewCompat.postOnAnimation(TwoWayView.this, this);
+                        return;
+                    }
+
+                    mLastSeenPosition = firstPosition;
+
+                    final int childCount = getChildCount();
+                    final int position = mTargetPosition;
+                    final int lastPos = firstPosition + childCount - 1;
+
+                    int viewTravelCount = 0;
+                    if (position < firstPosition) {
+                        viewTravelCount = firstPosition - position + 1;
+                    } else if (position > lastPos) {
+                        viewTravelCount = position - lastPos;
+                    }
+
+                    // Estimate how many screens we should travel
+                    final float screenTravelCount = (float) viewTravelCount / childCount;
+
+                    final float modifier = Math.min(Math.abs(screenTravelCount), 1.f);
+                    if (position < firstPosition) {
+                        final int distance = (int) (-getSize() * modifier);
+                        final int duration = (int) (mScrollDuration * modifier);
+                        smoothScrollBy(distance, duration);
+                        ViewCompat.postOnAnimation(TwoWayView.this, this);
+                    } else if (position > lastPos) {
+                        final int distance = (int) (getSize() * modifier);
+                        final int duration = (int) (mScrollDuration * modifier);
+                        smoothScrollBy(distance, duration);
+                        ViewCompat.postOnAnimation(TwoWayView.this, this);
+                    } else {
+                        // On-screen, just scroll.
+                        final View targetView = getChildAt(position - firstPosition);
+                        final int targetStart = getChildStartEdge(targetView);
+                        final int distance = targetStart - mOffsetFromStart;
+                        final int duration = (int) (mScrollDuration *
+                                ((float) Math.abs(distance) / getSize()));
+                        smoothScrollBy(distance, duration);
+                    }
+
+                    break;
+                }
+
+                default:
+                    break;
+            }
+        }
+    }
 }