Add move operation to RecyclerView adapter

Bug: 16127440

Change-Id: Iefd305e714a4bc6dad5f071dfba92aa5c5af8ca3
diff --git a/v7/recyclerview/src/android/support/v7/widget/AdapterHelper.java b/v7/recyclerview/src/android/support/v7/widget/AdapterHelper.java
index 5a56e1f..5f80bd9 100644
--- a/v7/recyclerview/src/android/support/v7/widget/AdapterHelper.java
+++ b/v7/recyclerview/src/android/support/v7/widget/AdapterHelper.java
@@ -17,6 +17,7 @@
 package android.support.v7.widget;
 
 import android.support.v4.util.Pools;
+import android.util.Log;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -48,6 +49,10 @@
 
     final static int POSITION_TYPE_NEW_OR_LAID_OUT = 1;
 
+    private static final boolean DEBUG = false;
+
+    private static final String TAG = "AHT";
+
     private Pools.Pool<UpdateOp> mUpdateOpPool = new Pools.SimplePool<UpdateOp>(UpdateOp.POOL_SIZE);
 
     final ArrayList<UpdateOp> mPendingUpdates = new ArrayList<UpdateOp>();
@@ -80,6 +85,7 @@
     }
 
     void preProcess() {
+        pruneInvalidMoveOps();
         final int count = mPendingUpdates.size();
         for (int i = 0; i < count; i++) {
             UpdateOp op = mPendingUpdates.get(i);
@@ -93,6 +99,9 @@
                 case UpdateOp.UPDATE:
                     applyUpdate(op);
                     break;
+                case UpdateOp.MOVE:
+                    applyMove(op);
+                    break;
             }
             if (mOnItemProcessedCallback != null) {
                 mOnItemProcessedCallback.run();
@@ -101,6 +110,145 @@
         mPendingUpdates.clear();
     }
 
+    /**
+     * When an item is moved, we still represent it in pre-layout even if we don't have a
+     * ViewHolder for it. This may be a problem if item is removed in the same layout pass.
+     * This is why we make sure item is not removed and if it is removed, we replace MOVE op
+     * with a REMOVE op and update the remaining UpdateOps accordingly.
+     */
+    private void pruneInvalidMoveOps() {
+        for (int i = mPendingUpdates.size() - 1; i >= 0; i--) {
+            UpdateOp op = mPendingUpdates.get(i);
+            if (op.cmd != UpdateOp.MOVE) {
+                continue;
+            }
+            // IF MOVE(from) is a newly added item, we defer it gracefully.
+            // IF MOVE(to) is removed, we have to invalidate MOVE
+            final int count = mPendingUpdates.size();
+            int to = op.itemCount;
+            int removedIndex = -1;
+            for (int j = i + 1; j < count; j++) {
+                UpdateOp other = mPendingUpdates.get(j);
+                if (other.cmd == UpdateOp.ADD && other.positionStart <= to) {
+                    to += other.itemCount;
+                } else if (other.cmd == UpdateOp.REMOVE && other.positionStart <= to) {
+                    if (other.positionStart + other.itemCount > to) {
+                        removedIndex = j;
+                        break;
+                    } else {
+                        to -= other.itemCount;
+                    }
+                } else if (other.cmd == UpdateOp.MOVE) {
+                    if (to == other.positionStart) {
+                        to = other.itemCount;
+                    } else {
+                        if (to > other.positionStart) {
+                            to--;
+                        }
+                        if (to >= other.itemCount) {
+                            to++;
+                        }
+                    }
+                }
+            }
+            if (removedIndex != -1) {
+                if (DEBUG) {
+                    Log.d(TAG,
+                            "detected a move that has been removed at" + removedIndex + ":" + op);
+                }
+                to = op.itemCount;
+                op.itemCount = 1;
+                op.cmd = UpdateOp.REMOVE;
+                // now, update all remaining ops according to this :/.
+                for (int j = i + 1; j <= removedIndex; j++) {
+                    UpdateOp other = mPendingUpdates.get(j);
+                    switch (other.cmd) {
+                        case UpdateOp.ADD:
+                            if (other.positionStart > to) {
+                                other.positionStart--;
+                            } else {
+                                to += other.itemCount;
+                            }
+                            break;
+                        case UpdateOp.UPDATE:
+                            if (other.positionStart > to) {
+                                other.positionStart--;
+                            } else if (other.positionStart <= to
+                                    && to < other.positionStart + other.itemCount) {
+                                other.itemCount--;
+                            }
+                            break;
+                        case UpdateOp.REMOVE:
+                            if (other.positionStart <= to) {
+                                if (other.positionStart + other.itemCount > to) {
+                                    other.itemCount--;
+                                } else {
+                                    to -= other.itemCount;
+                                }
+                            } else {
+                                other.positionStart--;
+                            }
+                            break;
+                        case UpdateOp.MOVE:
+                            if (to == other.positionStart) {
+                                throw new IllegalStateException("move updated removed move. "
+                                        + "Should've detected earlier");
+                            } else {
+                                final int start, end, inBetweenDiff;
+                                if (other.positionStart > other.itemCount) {
+                                    start = other.itemCount;
+                                    end = other.positionStart;
+                                    inBetweenDiff = 1;
+                                } else {
+                                    start = other.positionStart;
+                                    end = other.itemCount;
+                                    inBetweenDiff = -1;
+                                }
+                                // since to is not added anymore, offset other if necessary
+                                if (to < other.positionStart) {
+                                    other.positionStart--;
+                                }
+                                if (to <= other.itemCount) {
+                                    other.itemCount--;
+                                }
+                                // now apply other to "to" position
+                                if (start <= to && end >= to) {
+                                    to += inBetweenDiff;
+                                }
+                            }
+                            break;
+                    }
+                }
+
+                for (int j = removedIndex; j > i; j--) {
+                    UpdateOp other = mPendingUpdates.get(j);
+                    if (other.cmd != UpdateOp.MOVE && other.itemCount <= 0) {
+                        if (DEBUG) {
+                            Log.d(TAG, "Recycling no-op " + other);
+                        }
+                        mPendingUpdates.remove(j);
+                        recycleUpdateOp(other);
+                    } else if (other.cmd == UpdateOp.MOVE &&
+                            (other.itemCount == other.positionStart ||
+                                    other.itemCount < 0)) {
+                        if (DEBUG) {
+                            Log.d(TAG, "Recycling no-op " + other);
+                        }
+                        mPendingUpdates.remove(j);
+                        recycleUpdateOp(other);
+                    }
+                }
+            }
+        }
+        if (DEBUG) {
+            Log.d(TAG, "after pruning is complete.");
+            for (UpdateOp op : mPendingUpdates) {
+                Log.d(TAG, op.toString());
+            }
+            Log.d(TAG, "------");
+        }
+    }
+
     void consumePostponedUpdates() {
         final int count = mPostponedList.size();
         for (int i = 0; i < count; i++) {
@@ -109,6 +257,13 @@
         recycleUpdateOpsAndClearList(mPostponedList);
     }
 
+    private void applyMove(UpdateOp op) {
+        // MOVE ops are pre-processed so at this point, we know that item is still in the adapter.
+        // otherwise, it would be converted into a REMOVE operation
+        mCallback.offsetPositionsForMove(op.positionStart, op.itemCount);
+        postpone(op);
+    }
+
     private void applyRemove(UpdateOp op) {
         int tmpStart = op.positionStart;
         int tmpCount = 0;
@@ -117,7 +272,7 @@
         for (int position = op.positionStart; position < tmpEnd; position++) {
             boolean typeChanged = false;
             ViewHolder vh = mCallback.findViewHolder(position);
-            if (vh != null || isNewlyAdded(position)) {
+            if (vh != null || canFindInPreLayout(position)) {
                 // If a ViewHolder exists or this is a newly added item, we can defer this update
                 // to post layout stage.
                 // * For existing ViewHolders, we'll fake its existence in the pre-layout phase.
@@ -176,7 +331,7 @@
         int type = -1;
         for (int position = op.positionStart; position < tmpEnd; position++) {
             ViewHolder vh = mCallback.findViewHolder(position);
-            if (vh != null || isNewlyAdded(position)) { // deferred
+            if (vh != null || canFindInPreLayout(position)) { // deferred
                 if (type == POSITION_TYPE_INVISIBLE) {
                     UpdateOp newOp = obtainUpdateOp(UpdateOp.UPDATE, tmpStart, tmpCount);
                     mCallback.markViewHoldersUpdated(newOp.positionStart, newOp.itemCount);
@@ -218,29 +373,176 @@
         // tricky part.
         // traverse all postpones and revert their changes on this op if necessary, apply updated
         // dispatch to them since now they are after this op.
+        if (op.cmd == UpdateOp.ADD || op.cmd == UpdateOp.MOVE) {
+            throw new IllegalArgumentException("should not dispatch add or move for pre layout");
+        }
+        if (DEBUG) {
+            Log.d(TAG, "dispat (pre)" + op);
+            Log.d(TAG, "postponed state before:");
+            for (UpdateOp updateOp : mPostponedList) {
+                Log.d(TAG, updateOp.toString());
+            }
+            Log.d(TAG, "----");
+        }
+
+        // handle each pos 1 by 1 to ensure continuity. If it breaks, dispatch partial
+        int tmpStart = updatePositionWithPostponed(op.positionStart, op.cmd);
+        if (DEBUG) {
+            Log.d(TAG, "pos:" + op.positionStart + ",updatedPos:" + tmpStart);
+        }
+        int tmpCnt = 1;
+        for (int p = 1; p < op.itemCount; p++) {
+            int pos = -1;
+            switch (op.cmd) {
+                case UpdateOp.UPDATE:
+                    pos = op.positionStart + p;
+                    break;
+                case UpdateOp.REMOVE:
+                    pos = op.positionStart;
+            }
+            int updatedPos = updatePositionWithPostponed(pos, op.cmd);
+            if (DEBUG) {
+                Log.d(TAG, "pos:" + pos + ",updatedPos:" + updatedPos);
+            }
+            boolean continuous = false;
+            switch (op.cmd) {
+                case UpdateOp.UPDATE:
+                    continuous = updatedPos == tmpStart + 1;
+                    break;
+                case UpdateOp.REMOVE:
+                    continuous = updatedPos == tmpStart;
+                    break;
+            }
+            if (continuous) {
+                tmpCnt++;
+            } else {
+                // need to dispatch this separately
+                UpdateOp tmp = obtainUpdateOp(op.cmd, tmpStart, tmpCnt);
+                if (DEBUG) {
+                    Log.d(TAG, "need to dispatch separately " + tmp);
+                }
+                mCallback.onDispatchFirstPass(tmp);
+                recycleUpdateOp(tmp);
+                tmpStart = updatedPos;// need to remove previously dispatched
+                tmpCnt = 1;
+            }
+        }
+        recycleUpdateOp(op);
+        if (tmpCnt > 0) {
+            UpdateOp tmp = obtainUpdateOp(op.cmd, tmpStart, tmpCnt);
+            if (DEBUG) {
+                Log.d(TAG, "dispatching:" + tmp);
+            }
+            mCallback.onDispatchFirstPass(tmp);
+            recycleUpdateOp(tmp);
+        }
+        if (DEBUG) {
+            Log.d(TAG, "post dispatch");
+            Log.d(TAG, "postponed state after:");
+            for (UpdateOp updateOp : mPostponedList) {
+                Log.d(TAG, updateOp.toString());
+            }
+            Log.d(TAG, "----");
+        }
+    }
+
+    private int updatePositionWithPostponed(int pos, int cmd) {
         final int count = mPostponedList.size();
         for (int i = count - 1; i >= 0; i--) {
             UpdateOp postponed = mPostponedList.get(i);
-            if (postponed.positionStart <= op.positionStart) {
-                op.positionStart += postponed.itemCount;
+            if (postponed.cmd == UpdateOp.MOVE) {
+                int start, end;
+                if (postponed.positionStart < postponed.itemCount) {
+                    start = postponed.positionStart;
+                    end = postponed.itemCount;
+                } else {
+                    start = postponed.itemCount;
+                    end = postponed.positionStart;
+                }
+                if (pos >= start && pos <= end) {
+                    //i'm affected
+                    if (start == postponed.positionStart) {
+                        if (cmd == UpdateOp.ADD) {
+                            postponed.itemCount++;
+                        } else if (cmd == UpdateOp.REMOVE) {
+                            postponed.itemCount--;
+                        }
+                        // op moved to left, move it right to revert
+                        pos++;
+                    } else {
+                        if (cmd == UpdateOp.ADD) {
+                            postponed.positionStart++;
+                        } else if (cmd == UpdateOp.REMOVE) {
+                            postponed.positionStart--;
+                        }
+                        // op was moved right, move left to revert
+                        pos--;
+                    }
+                } else if (pos < postponed.positionStart) {
+                    // postponed MV is outside the dispatched OP. if it is before, offset
+                    if (cmd == UpdateOp.ADD) {
+                        postponed.positionStart++;
+                        postponed.itemCount++;
+                    } else if (cmd == UpdateOp.REMOVE) {
+                        postponed.positionStart--;
+                        postponed.itemCount--;
+                    }
+                }
             } else {
-                postponed.positionStart -= op.itemCount;
+                if (postponed.positionStart <= pos) {
+                    if (postponed.cmd == UpdateOp.ADD) {
+                        pos -= postponed.itemCount;
+                    } else if (postponed.cmd == UpdateOp.REMOVE) {
+                        pos += postponed.itemCount;
+                    }
+                } else {
+                    if (cmd == UpdateOp.ADD) {
+                        postponed.positionStart++;
+                    } else if (cmd == UpdateOp.REMOVE) {
+                        postponed.positionStart--;
+                    }
+                }
+            }
+            if (DEBUG) {
+                Log.d(TAG, "dispath (step" + i + ")");
+                Log.d(TAG, "postponed state:" + i + ", pos:" + pos);
+                for (UpdateOp updateOp : mPostponedList) {
+                    Log.d(TAG, updateOp.toString());
+                }
+                Log.d(TAG, "----");
             }
         }
-        mCallback.onDispatchFirstPass(op);
-        recycleUpdateOp(op);
+        for (int i = mPostponedList.size() - 1; i >= 0; i--) {
+            UpdateOp op = mPostponedList.get(i);
+            if (op.cmd == UpdateOp.MOVE) {
+                if (op.itemCount == op.positionStart || op.itemCount < 0) {
+                    mPostponedList.remove(i);
+                    recycleUpdateOp(op);
+                }
+            } else if (op.itemCount <= 0) {
+                mPostponedList.remove(i);
+                recycleUpdateOp(op);
+            }
+        }
+        return pos;
     }
 
-    private boolean isNewlyAdded(int position) {
+    private boolean canFindInPreLayout(int position) {
         final int count = mPostponedList.size();
         for (int i = 0; i < count; i++) {
             UpdateOp op = mPostponedList.get(i);
-            if (op.cmd != UpdateOp.ADD) {
-                continue;
-            }
-            int updatedStart = findPositionOffset(op.positionStart, i + 1);
-            if (updatedStart <= position && updatedStart + op.itemCount > position) {
-                return true;
+            if (op.cmd == UpdateOp.MOVE) {
+                if (op.positionStart == position) {
+                    return true;
+                }
+            } else if (op.cmd == UpdateOp.ADD) {
+                // TODO optimize.
+                final int end = op.positionStart + op.itemCount;
+                for (int pos = op.positionStart; pos < end; pos++) {
+                    if (findPositionOffset(pos, i + 1) == position) {
+                        return true;
+                    }
+                }
             }
         }
         return false;
@@ -252,6 +554,9 @@
     }
 
     private void postpone(UpdateOp op) {
+        if (DEBUG) {
+            Log.d(TAG, "postponing " + op);
+        }
         mPostponedList.add(op);
     }
 
@@ -264,19 +569,28 @@
     }
 
     int findPositionOffset(int position, int firstPostponedItem) {
-        int offsetPosition = position;
         int count = mPostponedList.size();
         for (int i = firstPostponedItem; i < count; ++i) {
             UpdateOp op = mPostponedList.get(i);
-            if (op.positionStart <= offsetPosition) {
+            if (op.cmd == UpdateOp.MOVE) {
+                if (op.positionStart == position) {
+                    // TODO check if this should be returned instead. probably not
+                    position = op.itemCount;
+                } else if (op.positionStart < position) {
+                    position--; // like a remove
+                }
+                if (op.itemCount <= position) {
+                    position++; // like an add
+                }
+            } else if (op.positionStart <= position) {
                 if (op.cmd == UpdateOp.REMOVE) {
-                    offsetPosition -= op.itemCount;
+                    position -= op.itemCount;
                 } else if (op.cmd == UpdateOp.ADD) {
-                    offsetPosition += op.itemCount;
+                    position += op.itemCount;
                 }
             }
         }
-        return offsetPosition;
+        return position;
     }
 
     /**
@@ -304,6 +618,20 @@
     }
 
     /**
+     * @return True if updates should be processed.
+     */
+    boolean onItemRangeMoved(int from, int to, int itemCount) {
+        if (from == to) {
+            return false;//no-op
+        }
+        if (itemCount != 1) {
+            throw new IllegalArgumentException("Moving more than 1 item is not supported yet");
+        }
+        mPendingUpdates.add(obtainUpdateOp(UpdateOp.MOVE, from, to));
+        return mPendingUpdates.size() == 1;
+    }
+
+    /**
      * Skips pre-processing and applies all updates in one pass.
      */
     void consumeUpdatesInOnePass() {
@@ -325,6 +653,9 @@
                 case UpdateOp.UPDATE:
                     mCallback.markViewHoldersUpdated(op.positionStart, op.itemCount);
                     break;
+                case UpdateOp.MOVE:
+                    mCallback.offsetPositionsForMove(op.positionStart, op.itemCount);
+                    break;
             }
             if (mOnItemProcessedCallback != null) {
                 mOnItemProcessedCallback.run();
@@ -344,12 +675,15 @@
 
         static final int UPDATE = 2;
 
+        static final int MOVE = 3;
+
         static final int POOL_SIZE = 30;
 
         int cmd;
 
         int positionStart;
 
+        // holds the target position if this is a MOVE
         int itemCount;
 
         UpdateOp(int cmd, int positionStart, int itemCount) {
@@ -366,6 +700,8 @@
                     return "rm";
                 case UPDATE:
                     return "up";
+                case MOVE:
+                    return "mv";
             }
             return "??";
         }
@@ -452,5 +788,7 @@
         void onDispatchSecondPass(UpdateOp updateOp);
 
         void offsetPositionsForAdd(int positionStart, int itemCount);
+
+        void offsetPositionsForMove(int from, int to);
     }
 }
\ No newline at end of file
diff --git a/v7/recyclerview/src/android/support/v7/widget/RecyclerView.java b/v7/recyclerview/src/android/support/v7/widget/RecyclerView.java
index 5002ef4..2ae23d0 100644
--- a/v7/recyclerview/src/android/support/v7/widget/RecyclerView.java
+++ b/v7/recyclerview/src/android/support/v7/widget/RecyclerView.java
@@ -350,6 +350,9 @@
                     case UpdateOp.UPDATE:
                         mLayout.onItemsUpdated(RecyclerView.this, op.positionStart, op.itemCount);
                         break;
+                    case UpdateOp.MOVE:
+                        mLayout.onItemsMoved(RecyclerView.this, op.positionStart, op.itemCount, 1);
+                        break;
                 }
             }
 
@@ -363,6 +366,13 @@
                 offsetPositionRecordsForInsert(positionStart, itemCount);
                 mItemsAddedOrRemoved = true;
             }
+
+            @Override
+            public void offsetPositionsForMove(int from, int to) {
+                offsetPositionRecordsForMove(from, to);
+                // should we create mItemsMoved ?
+                mItemsAddedOrRemoved = true;
+            }
         });
     }
 
@@ -2046,6 +2056,40 @@
         mRecycler.clearOldPositions();
     }
 
+    void offsetPositionRecordsForMove(int from, int to) {
+        final int childCount = mChildHelper.getUnfilteredChildCount();
+        final int start, end, inBetweenOffset;
+        if (from < to) {
+            start = from;
+            end = to;
+            inBetweenOffset = -1;
+        } else {
+            start = to;
+            end = from;
+            inBetweenOffset = 1;
+        }
+
+        for (int i = 0; i < childCount; i++) {
+            final ViewHolder holder = getChildViewHolderInt(mChildHelper.getUnfilteredChildAt(i));
+            if (holder == null || holder.mPosition < start || holder.mPosition > end) {
+                continue;
+            }
+            if (DEBUG) {
+                Log.d(TAG, "offsetPositionRecordsForMove attached child " + i + " holder " +
+                        holder);
+            }
+            if (holder.mPosition == from) {
+                holder.offsetPosition(to - from, false);
+            } else {
+                holder.offsetPosition(inBetweenOffset, false);
+            }
+
+            mState.mStructureChanged = true;
+        }
+        mRecycler.offsetPositionRecordsForMove(from, to);
+        requestLayout();
+    }
+
     void offsetPositionRecordsForInsert(int positionStart, int itemCount) {
         final int childCount = mChildHelper.getUnfilteredChildCount();
         for (int i = 0; i < childCount; i++) {
@@ -2619,6 +2663,14 @@
             }
         }
 
+        @Override
+        public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
+            assertNotInLayoutOrScroll(null);
+            if (mAdapterHelper.onItemRangeMoved(fromPosition, toPosition, itemCount)) {
+                triggerUpdateProcessor();
+            }
+        }
+
         void triggerUpdateProcessor() {
             if (mPostUpdatesOnAnimation && mHasFixedSize && mIsAttached) {
                 ViewCompat.postOnAnimation(RecyclerView.this, mUpdateChildViewsRunnable);
@@ -3248,6 +3300,35 @@
             getRecycledViewPool().onAdapterChanged(oldAdapter, newAdapter);
         }
 
+        void offsetPositionRecordsForMove(int from, int to) {
+            final int start, end, inBetweenOffset;
+            if (from < to) {
+                start = from;
+                end = to;
+                inBetweenOffset = -1;
+            } else {
+                start = to;
+                end = from;
+                inBetweenOffset = 1;
+            }
+            final int cachedCount = mCachedViews.size();
+            for (int i = 0; i < cachedCount; i++) {
+                final ViewHolder holder = mCachedViews.get(i);
+                if (holder == null || holder.mPosition < start || holder.mPosition > end) {
+                    continue;
+                }
+                if (holder.mPosition == from) {
+                    holder.offsetPosition(to - from, false);
+                } else {
+                    holder.offsetPosition(inBetweenOffset, false);
+                }
+                if (DEBUG) {
+                    Log.d(TAG, "offsetPositionRecordsForMove cached child " + i + " holder " +
+                            holder);
+                }
+            }
+        }
+
         void offsetPositionRecordsForInsert(int insertedAt, int count) {
             final int cachedCount = mCachedViews.size();
             for (int i = 0; i < cachedCount; i++) {
@@ -3650,6 +3731,21 @@
         }
 
         /**
+         * Notify any registered observers that the item reflected at <code>fromPosition</code>
+         * has been moved to <code>toPosition</code>.
+         *
+         * <p>This is a structural change event. Representations of other existing items in the
+         * data set are still considered up to date and will not be rebound, though their
+         * positions may be altered.</p>
+         *
+         * @param fromPosition Previous position of the item.
+         * @param toPosition New position of the item.
+         */
+        public final void notifyItemMoved(int fromPosition, int toPosition) {
+            mObservable.notifyItemMoved(fromPosition, toPosition);
+        }
+
+        /**
          * Notify any registered observers that the currently reflected <code>itemCount</code>
          * items starting at <code>positionStart</code> have been newly inserted. The items
          * previously located at <code>positionStart</code> and beyond can now be found starting
@@ -5120,6 +5216,22 @@
         public void onItemsUpdated(RecyclerView recyclerView, int positionStart, int itemCount) {
         }
 
+        /**
+         * Called when an item is moved withing the adapter.
+         * <p>
+         * Note that, an item may also change position in response to another ADD/REMOVE/MOVE
+         * operation. This callback is only called if and only if {@link Adapter#notifyItemMoved}
+         * is called.
+         *
+         * @param recyclerView
+         * @param from
+         * @param to
+         * @param itemCount
+         */
+        public void onItemsMoved(RecyclerView recyclerView, int from, int to, int itemCount) {
+
+        }
+
 
         /**
          * <p>Override this method if you want to support scroll bars.</p>
@@ -5867,6 +5979,10 @@
         public void onItemRangeRemoved(int positionStart, int itemCount) {
             // do nothing
         }
+
+        public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
+            // do nothing
+        }
     }
 
     /**
@@ -6280,6 +6396,12 @@
                 mObservers.get(i).onItemRangeRemoved(positionStart, itemCount);
             }
         }
+
+        public void notifyItemMoved(int fromPosition, int toPosition) {
+            for (int i = mObservers.size() - 1; i >= 0; i--) {
+                mObservers.get(i).onItemRangeMoved(fromPosition, toPosition, 1);
+            }
+        }
     }
 
     static class SavedState extends android.view.View.BaseSavedState {
diff --git a/v7/recyclerview/tests/src/android/support/v7/widget/AdapterHelperTest.java b/v7/recyclerview/tests/src/android/support/v7/widget/AdapterHelperTest.java
index d2708c3..38ad9c7 100644
--- a/v7/recyclerview/tests/src/android/support/v7/widget/AdapterHelperTest.java
+++ b/v7/recyclerview/tests/src/android/support/v7/widget/AdapterHelperTest.java
@@ -17,6 +17,7 @@
 package android.support.v7.widget;
 
 import android.test.AndroidTestCase;
+import android.util.Log;
 import android.widget.TextView;
 
 import java.util.ArrayList;
@@ -30,7 +31,9 @@
 
 public class AdapterHelperTest extends AndroidTestCase {
 
-    static final int NO_POSITION = -1;
+    private static final boolean DEBUG = false;
+
+    private static final String TAG = "AHT";
 
     List<ViewHolder> mViewHolders;
 
@@ -42,12 +45,15 @@
 
     TestAdapter mPreProcessClone; // we clone adapter pre-process to run operations to see result
 
+    private List<TestAdapter.Item> mPreLayoutItems;
+
     @Override
     protected void setUp() throws Exception {
         cleanState();
     }
 
     private void cleanState() {
+        mPreLayoutItems = new ArrayList<TestAdapter.Item>();
         mViewHolders = new ArrayList<ViewHolder>();
         mFirstPassUpdates = new ArrayList<AdapterHelper.UpdateOp>();
         mSecondPassUpdates = new ArrayList<AdapterHelper.UpdateOp>();
@@ -101,11 +107,17 @@
 
             @Override
             public void onDispatchFirstPass(AdapterHelper.UpdateOp updateOp) {
+                if (DEBUG) {
+                    Log.d(TAG, "first pass:" + updateOp.toString());
+                }
                 mFirstPassUpdates.add(updateOp);
             }
 
             @Override
             public void onDispatchSecondPass(AdapterHelper.UpdateOp updateOp) {
+                if (DEBUG) {
+                    Log.d(TAG, "second pass:" + updateOp.toString());
+                }
                 mSecondPassUpdates.add(updateOp);
             }
 
@@ -117,10 +129,33 @@
                     }
                 }
             }
+
+            @Override
+            public void offsetPositionsForMove(int from, int to) {
+                final int start, end, inBetweenOffset;
+                if (from < to) {
+                    start = from;
+                    end = to;
+                    inBetweenOffset = -1;
+                } else {
+                    start = to;
+                    end = from;
+                    inBetweenOffset = 1;
+                }
+                for (ViewHolder holder : mViewHolders) {
+                    if (holder == null || holder.mPosition < start || holder.mPosition > end) {
+                        continue;
+                    }
+                    holder.offsetPosition(inBetweenOffset, false);
+                }
+            }
         }, true);
     }
 
     void setupBasic(int count, int visibleStart, int visibleCount) {
+        if (DEBUG) {
+            Log.d(TAG, "setupBasic(" + count + "," + visibleStart + "," + visibleCount + ");");
+        }
         mTestAdapter = new TestAdapter(count, mAdapterHelper);
         for (int i = 0; i < visibleCount; i++) {
             addViewHolder(visibleStart + i);
@@ -185,7 +220,7 @@
         assertDispatch(1, 1);
     }
 
-    public void testSchenerio1() {
+    public void testScenario1() {
         setupBasic(10, 3, 2);
         rm(4, 1);
         rm(3, 1);
@@ -201,7 +236,7 @@
         assertDispatch(1, 1);
     }
 
-    public void testSchenerio2() {
+    public void testScenario2() {
         setupBasic(10, 3, 3); // 3-4-5
         add(4, 2); // 3 a b 4 5
         rm(0, 1); // (0) 3(2) a(3) b(4) 4(3) 5(4)
@@ -210,7 +245,7 @@
         assertDispatch(2, 2);
     }
 
-    public void testSchenerio3() {
+    public void testScenario3() {
         setupBasic(10, 2, 2);
         rm(0, 5);
         preProcess();
@@ -218,8 +253,10 @@
         assertOps(mFirstPassUpdates, rmOp(0, 2), rmOp(2, 1));
         assertOps(mSecondPassUpdates, rmOp(0, 2));
     }
+    // TODO test MOVE then remove items in between.
+    // TODO test MOVE then remove it, make sure it is not dispatched
 
-    public void testSchenerio4() {
+    public void testScenario4() {
         setupBasic(5, 0, 5);
         // 0 1 2 3 4
         // 0 1 2 a b 3 4
@@ -236,7 +273,7 @@
         preProcess();
     }
 
-    public void testSchenerio5() {
+    public void testScenario5() {
         setupBasic(5, 0, 5);
         // 0 1 2 3 4
         // 0 1 2 a b 3 4
@@ -248,34 +285,244 @@
         preProcess();
     }
 
+    public void testScenario6() {
+//        setupBasic(47, 19, 24);
+//        mv(11, 12);
+//        add(24, 16);
+//        rm(9, 3);
+        setupBasic(10, 5, 3);
+        mv(2, 3);
+        add(6, 4);
+        rm(4, 1);
+        preProcess();
+    }
+
+    public void testScenario8() {
+        setupBasic(68, 51, 13);
+        mv(22, 11);
+        mv(22, 52);
+        rm(37, 19);
+        add(12, 38);
+        preProcess();
+    }
+
+    public void testScenario9() {
+        setupBasic(44, 3, 7);
+        add(7, 21);
+        rm(31, 3);
+        rm(32, 11);
+        mv(29, 5);
+        mv(30, 32);
+        add(25, 32);
+        rm(15, 66);
+        preProcess();
+    }
+
+    public void testScenario10() {
+        setupBasic(14, 10, 3);
+        rm(4, 4);
+        add(5, 11);
+        mv(5, 18);
+        rm(2, 9);
+        preProcess();
+    }
+
+    public void testScenario11() {
+        setupBasic(78, 3, 64);
+        mv(34, 28);
+        add(1, 11);
+        rm(9, 74);
+        preProcess();
+    }
+
+    public void testScenario12() {
+        setupBasic(38, 9, 7);
+        rm(26, 3);
+        mv(29, 15);
+        rm(30, 1);
+        preProcess();
+    }
+
+    public void testScenario13() {
+        setupBasic(49, 41, 3);
+        rm(30, 13);
+        add(4, 10);
+        mv(3, 38);
+        mv(20, 17);
+        rm(18, 23);
+        preProcess();
+    }
+
+    public void testScenario14() {
+        setupBasic(24, 3, 11);
+        rm(2, 15);
+        mv(2, 1);
+        add(2, 34);
+        add(11, 3);
+        rm(10, 25);
+        rm(13, 6);
+        rm(4, 4);
+        rm(6, 4);
+        preProcess();
+    }
+
+    public void testScenario15() {
+        setupBasic(10, 8, 1);
+        mv(6, 1);
+        mv(1, 4);
+        rm(3, 1);
+        preProcess();
+    }
+
+    public void testScenario16() {
+        setupBasic(10, 3, 3);
+        rm(2, 1);
+        rm(1, 7);
+        rm(0, 1);
+        preProcess();
+    }
+
+    public void testScenario17() {
+        setupBasic(10, 8, 1);
+        mv(1, 0);
+        mv(5, 1);
+        rm(1, 7);
+        preProcess();
+    }
+
+    public void testScenario18() throws InterruptedException {
+        setupBasic(10, 1, 4);
+        add(2, 11);
+        rm(16, 1);
+        add(3, 1);
+        rm(9, 10);
+        preProcess();
+    }
+
+    public void testScenario19() {
+        setupBasic(10,8,1);
+        mv(9,7);
+        mv(9,3);
+        rm(5,4);
+        preProcess();
+    }
+
+    public void testScenario20() {
+        setupBasic(10,7,1);
+        mv(9,1);
+        mv(3,9);
+        rm(7,2);
+        preProcess();
+    }
+
+    public void testScenario21() {
+        setupBasic(10,5,2);
+        mv(1,0);
+        mv(9,1);
+        rm(2,3);
+        preProcess();
+    }
+
+    public void testScenario22() {
+        setupBasic(10,7,2);
+        add(2,16);
+        mv(20,9);
+        rm(17,6);
+        preProcess();
+    }
+
+    public void testScenario23() {
+        setupBasic(10,5,3);
+        mv(9,6);
+        add(4,15);
+        rm(21,3);
+        preProcess();
+    }
+
+    public void testScenario24() {
+        setupBasic(10,1,6);
+        add(6,5);
+        mv(14,6);
+        rm(7,6);
+        preProcess();
+    }
+
+    public void testScenario25() {
+        setupBasic(10,3,4);
+        mv(3,9);
+        mv(2,9);
+        rm(5,4);
+        preProcess();
+    }
+
+    public void testScenario26() {
+        setupBasic(10,4,4);
+        rm(3,5);
+        mv(2,0);
+        mv(1,0);
+        rm(1,1);
+        mv(0,2);
+        preProcess();
+    }
+
+    public void testScenario27() {
+        setupBasic(10,0,3);
+        mv(9,4);
+        mv(8,4);
+        add(7,10);
+        rm(5,5);
+        mv(6,5);
+        preProcess();
+    }
+
+    public void testMoveAdded() {
+        setupBasic(10, 2, 2);
+        add(3, 5);
+        mv(4, 2);
+        preProcess();
+    }
+
     public void testRandom() {
         Random random = new Random(System.nanoTime());
-        for (int i = 0; i < 10; i++) {
-            randomTest(random, i);
+        for (int i = 0; i < 1000; i++) {
+            randomTest(random, 5);
         }
     }
 
     public void randomTest(Random random, int opCount) {
         cleanState();
-        final int count = 10 + random.nextInt(100);
+        if (DEBUG) {
+            Log.d(TAG, "randomTest");
+        }
+        final int count = 10;// + random.nextInt(100);
         final int start = random.nextInt(count - 1);
         final int layoutCount = Math.max(1, random.nextInt(count - start));
         setupBasic(count, start, layoutCount);
 
         while (opCount-- > 0) {
-            final int op = random.nextInt(AdapterHelper.UpdateOp.REMOVE);
+            final int op = random.nextInt(3);
             switch (op) {
-                case AdapterHelper.UpdateOp.REMOVE:
+                case 0:
                     if (mTestAdapter.mItems.size() > 1) {
                         int s = random.nextInt(mTestAdapter.mItems.size() - 1);
                         int len = Math.max(1, random.nextInt(mTestAdapter.mItems.size() - s));
                         rm(s, len);
                     }
                     break;
-                case AdapterHelper.UpdateOp.ADD:
-                    int s = random.nextInt(mTestAdapter.mItems.size() - 1);
+                case 1:
+                    int s = mTestAdapter.mItems.size() == 0 ? 0 :
+                            random.nextInt(mTestAdapter.mItems.size());
                     add(s, random.nextInt(50));
                     break;
+                case 2:
+                    if (mTestAdapter.mItems.size() >= 2) {
+                        int from = random.nextInt(mTestAdapter.mItems.size());
+                        int to;
+                        do {
+                            to = random.nextInt(mTestAdapter.mItems.size());
+                        } while (to == from);
+                        mv(from, to);
+                    }
             }
         }
         preProcess();
@@ -296,6 +543,16 @@
 
     void preProcess() {
         mAdapterHelper.preProcess();
+        for (int i = 0; i < mPreProcessClone.mItems.size(); i++) {
+            TestAdapter.Item item = mPreProcessClone.mItems.get(i);
+            final int preLayoutIndex = mPreLayoutItems.indexOf(item);
+            final int endIndex = mTestAdapter.mItems.indexOf(item);
+            if (preLayoutIndex != -1 && endIndex != -1) {
+                assertEquals("find position offset should work properly for moved element " + i
+                        + " at pre layout position " + preLayoutIndex + " and post layout position "
+                        + endIndex, endIndex, mAdapterHelper.findPositionOffset(preLayoutIndex));
+            }
+        }
         mAdapterHelper.consumePostponedUpdates();
         // now assert these two adapters have identical data.
         mPreProcessClone.applyOps(mFirstPassUpdates, mTestAdapter);
@@ -331,10 +588,38 @@
     }
 
     void add(int start, int count) {
+        if (DEBUG) {
+            Log.d(TAG, "add(" + start + "," + count + ");");
+        }
         mTestAdapter.add(start, count);
     }
 
+    boolean isItemLaidOut(int pos) {
+        for (ViewHolder viewHolder : mViewHolders) {
+            if (viewHolder.mOldPosition == pos) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private void mv(int from, int to) {
+        if (DEBUG) {
+            Log.d(TAG, "mv(" + from + "," + to + ");");
+        }
+        mTestAdapter.move(from, to);
+    }
+
     void rm(int start, int count) {
+        if (DEBUG) {
+            Log.d(TAG, "rm(" + start + "," + count + ");");
+        }
+        for (int i = start; i < start + count; i++) {
+            if (!isItemLaidOut(i)) {
+                TestAdapter.Item item = mTestAdapter.mItems.get(i);
+                mPreLayoutItems.remove(item);
+            }
+        }
         mTestAdapter.remove(start, count);
     }
 
@@ -370,6 +655,12 @@
             ));
         }
 
+        public void move(int from, int to) {
+            mItems.add(to, mItems.remove(from));
+            mAdapterHelper.addUpdateOp(new AdapterHelper.UpdateOp(
+                    AdapterHelper.UpdateOp.MOVE, from, to
+            ));
+        }
         public void remove(int index, int count) {
             for (int i = 0; i < count; i++) {
                 mItems.remove(index);
@@ -415,6 +706,9 @@
                             mItems.get(i).handleUpdate();
                         }
                         break;
+                    case AdapterHelper.UpdateOp.MOVE:
+                        mItems.add(op.itemCount, mItems.remove(op.positionStart));
+                        break;
                 }
             }
         }