diff --git a/build.gradle b/build.gradle index d11b976..58ecbd0 100644 --- a/build.gradle +++ b/build.gradle @@ -16,7 +16,7 @@ android { } dependencies { - provided 'com.android.support:support-annotations:25.3.1' + provided 'com.android.support:support-annotations:25.4.0' provided 'io.reactivex:rxandroid:1.2.1' provided 'io.reactivex:rxjava:1.3.0' } diff --git a/src/main/java/ru/touchin/roboswag/core/android/support/v7/util/BatchingListUpdateCallback.java b/src/main/java/ru/touchin/roboswag/core/android/support/v7/util/BatchingListUpdateCallback.java new file mode 100644 index 0000000..1b801c6 --- /dev/null +++ b/src/main/java/ru/touchin/roboswag/core/android/support/v7/util/BatchingListUpdateCallback.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ru.touchin.roboswag.core.android.support.v7.util; + +/** + * Wraps a {@link ListUpdateCallback} callback and batches operations that can be merged. + * + *

For instance, when 2 add operations comes that adds 2 consecutive elements, + * BatchingListUpdateCallback merges them and calls the wrapped callback only once. + * + *

This is a general purpose class and is also used by + * {@link DiffUtil.DiffResult DiffResult} + * + *

If you use this class to batch updates, you must call {@link #dispatchLastEvent()} when the + * stream of update events drain. + */ +@SuppressWarnings({"PMD", "checkstyle:all"}) +public class BatchingListUpdateCallback implements ListUpdateCallback { + + private static final int TYPE_NONE = 0; + private static final int TYPE_ADD = 1; + private static final int TYPE_REMOVE = 2; + private static final int TYPE_CHANGE = 3; + + private final ListUpdateCallback mWrapped; + + private int mLastEventType = TYPE_NONE; + private int mLastEventPosition = -1; + private int mLastEventCount = -1; + private Object mLastEventPayload = null; + + public BatchingListUpdateCallback(ListUpdateCallback callback) { + mWrapped = callback; + } + + /** + * BatchingListUpdateCallback holds onto the last event to see if it can be merged with the + * next one. When stream of events finish, you should call this method to dispatch the last + * event. + */ + public void dispatchLastEvent() { + if (mLastEventType == TYPE_NONE) { + return; + } + switch (mLastEventType) { + case TYPE_ADD: + mWrapped.onInserted(mLastEventPosition, mLastEventCount); + break; + case TYPE_REMOVE: + mWrapped.onRemoved(mLastEventPosition, mLastEventCount); + break; + case TYPE_CHANGE: + mWrapped.onChanged(mLastEventPosition, mLastEventCount, mLastEventPayload); + break; + } + mLastEventPayload = null; + mLastEventType = TYPE_NONE; + } + + @Override + public void onInserted(int position, int count) { + if (mLastEventType == TYPE_ADD && position >= mLastEventPosition + && position <= mLastEventPosition + mLastEventCount) { + mLastEventCount += count; + mLastEventPosition = Math.min(position, mLastEventPosition); + return; + } + dispatchLastEvent(); + mLastEventPosition = position; + mLastEventCount = count; + mLastEventType = TYPE_ADD; + } + + @Override + public void onRemoved(int position, int count) { + if (mLastEventType == TYPE_REMOVE && mLastEventPosition >= position && + mLastEventPosition <= position + count) { + mLastEventCount += count; + mLastEventPosition = position; + return; + } + dispatchLastEvent(); + mLastEventPosition = position; + mLastEventCount = count; + mLastEventType = TYPE_REMOVE; + } + + @Override + public void onMoved(int fromPosition, int toPosition) { + dispatchLastEvent(); // moves are not merged + mWrapped.onMoved(fromPosition, toPosition); + } + + @Override + public void onChanged(int position, int count, Object payload) { + if (mLastEventType == TYPE_CHANGE && + !(position > mLastEventPosition + mLastEventCount + || position + count < mLastEventPosition || mLastEventPayload != payload)) { + // take potential overlap into account + int previousEnd = mLastEventPosition + mLastEventCount; + mLastEventPosition = Math.min(position, mLastEventPosition); + mLastEventCount = Math.max(previousEnd, position + count) - mLastEventPosition; + return; + } + dispatchLastEvent(); + mLastEventPosition = position; + mLastEventCount = count; + mLastEventPayload = payload; + mLastEventType = TYPE_CHANGE; + } + +} diff --git a/src/main/java/ru/touchin/roboswag/core/android/support/v7/util/DiffUtil.java b/src/main/java/ru/touchin/roboswag/core/android/support/v7/util/DiffUtil.java new file mode 100644 index 0000000..270c93b --- /dev/null +++ b/src/main/java/ru/touchin/roboswag/core/android/support/v7/util/DiffUtil.java @@ -0,0 +1,787 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ru.touchin.roboswag.core.android.support.v7.util; + +import android.support.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * DiffUtil is a utility class that can calculate the difference between two lists and output a + * list of update operations that converts the first list into the second one. + * + *

It can be used to calculate updates for a RecyclerView Adapter. + * + *

DiffUtil uses Eugene W. Myers's difference algorithm to calculate the minimal number of updates + * to convert one list into another. Myers's algorithm does not handle items that are moved so + * DiffUtil runs a second pass on the result to detect items that were moved. + * + *

If the lists are large, this operation may take significant time so you are advised to run this + * on a background thread, get the {@link DiffResult} then apply it on the RecyclerView on the main + * thread. + * + *

This algorithm is optimized for space and uses O(N) space to find the minimal + * number of addition and removal operations between the two lists. It has O(N + D^2) expected time + * performance where D is the length of the edit script. + * + *

If move detection is enabled, it takes an additional O(N^2) time where N is the total number of + * added and removed items. If your lists are already sorted by the same constraint (e.g. a created + * timestamp for a list of posts), you can disable move detection to improve performance. + * + *

The actual runtime of the algorithm significantly depends on the number of changes in the list + * and the cost of your comparison methods. Below are some average run times for reference: + * (The areSame list is composed of random UUID Strings and the tests are run on Nexus 5X with M) + *

+ * + *

Due to implementation constraints, the max size of the list can be 2^26. + */ +@SuppressWarnings({"PMD", "checkstyle:all"}) +public class DiffUtil { + + private DiffUtil() { + // utility class, no instance. + } + + private static final Comparator SNAKE_COMPARATOR = new Comparator() { + @Override + public int compare(Snake o1, Snake o2) { + int cmpX = o1.x - o2.x; + return cmpX == 0 ? o1.y - o2.y : cmpX; + } + }; + + // Myers' algorithm uses two lists as axis labels. In DiffUtil's implementation, `x` axis is + // used for old list and `y` axis is used for new list. + + /** + * Calculates the list of update operations that can covert one list into the other one. + * + * @param cb The callback that acts as a gateway to the backing list data + * + * @return A DiffResult that contains the information about the edit sequence to convert the + * old list into the new list. + */ + public static DiffResult calculateDiff(Callback cb) { + return calculateDiff(cb, true); + } + + /** + * Calculates the list of update operations that can covert one list into the other one. + *

+ * If your old and new lists are sorted by the same constraint and items never move (swap + * positions), you can disable move detection which takes O(N^2) time where + * N is the number of added, moved, removed items. + * + * @param cb The callback that acts as a gateway to the backing list data + * @param detectMoves True if DiffUtil should try to detect moved items, false otherwise. + * + * @return A DiffResult that contains the information about the edit sequence to convert the + * old list into the new list. + */ + public static DiffResult calculateDiff(Callback cb, boolean detectMoves) { + final int oldSize = cb.getOldListSize(); + final int newSize = cb.getNewListSize(); + + final List snakes = new ArrayList<>(); + + // instead of a recursive implementation, we keep our own stack to avoid potential stack + // overflow exceptions + final List stack = new ArrayList<>(); + + stack.add(new Range(0, oldSize, 0, newSize)); + + final int max = oldSize + newSize + Math.abs(oldSize - newSize); + // allocate forward and backward k-lines. K lines are diagonal lines in the matrix. (see the + // paper for details) + // These arrays lines keep the max reachable position for each k-line. + final int[] forward = new int[max * 2]; + final int[] backward = new int[max * 2]; + + // We pool the ranges to avoid allocations for each recursive call. + final List rangePool = new ArrayList<>(); + while (!stack.isEmpty()) { + final Range range = stack.remove(stack.size() - 1); + final Snake snake = diffPartial(cb, range.oldListStart, range.oldListEnd, + range.newListStart, range.newListEnd, forward, backward, max); + if (snake != null) { + if (snake.size > 0) { + snakes.add(snake); + } + // offset the snake to convert its coordinates from the Range's area to global + snake.x += range.oldListStart; + snake.y += range.newListStart; + + // add new ranges for left and right + final Range left = rangePool.isEmpty() ? new Range() : rangePool.remove( + rangePool.size() - 1); + left.oldListStart = range.oldListStart; + left.newListStart = range.newListStart; + if (snake.reverse) { + left.oldListEnd = snake.x; + left.newListEnd = snake.y; + } else { + if (snake.removal) { + left.oldListEnd = snake.x - 1; + left.newListEnd = snake.y; + } else { + left.oldListEnd = snake.x; + left.newListEnd = snake.y - 1; + } + } + stack.add(left); + + // re-use range for right + //noinspection UnnecessaryLocalVariable + final Range right = range; + if (snake.reverse) { + if (snake.removal) { + right.oldListStart = snake.x + snake.size + 1; + right.newListStart = snake.y + snake.size; + } else { + right.oldListStart = snake.x + snake.size; + right.newListStart = snake.y + snake.size + 1; + } + } else { + right.oldListStart = snake.x + snake.size; + right.newListStart = snake.y + snake.size; + } + stack.add(right); + } else { + rangePool.add(range); + } + + } + // sort snakes + Collections.sort(snakes, SNAKE_COMPARATOR); + + return new DiffResult(cb, snakes, forward, backward, detectMoves); + + } + + private static Snake diffPartial(Callback cb, int startOld, int endOld, + int startNew, int endNew, int[] forward, int[] backward, int kOffset) { + final int oldSize = endOld - startOld; + final int newSize = endNew - startNew; + + if (endOld - startOld < 1 || endNew - startNew < 1) { + return null; + } + + final int delta = oldSize - newSize; + final int dLimit = (oldSize + newSize + 1) / 2; + Arrays.fill(forward, kOffset - dLimit - 1, kOffset + dLimit + 1, 0); + Arrays.fill(backward, kOffset - dLimit - 1 + delta, kOffset + dLimit + 1 + delta, oldSize); + final boolean checkInFwd = delta % 2 != 0; + for (int d = 0; d <= dLimit; d++) { + for (int k = -d; k <= d; k += 2) { + // find forward path + // we can reach k from k - 1 or k + 1. Check which one is further in the graph + int x; + final boolean removal; + if (k == -d || k != d && forward[kOffset + k - 1] < forward[kOffset + k + 1]) { + x = forward[kOffset + k + 1]; + removal = false; + } else { + x = forward[kOffset + k - 1] + 1; + removal = true; + } + // set y based on x + int y = x - k; + // move diagonal as long as items match + while (x < oldSize && y < newSize + && cb.areItemsTheSame(startOld + x, startNew + y)) { + x++; + y++; + } + forward[kOffset + k] = x; + if (checkInFwd && k >= delta - d + 1 && k <= delta + d - 1) { + if (forward[kOffset + k] >= backward[kOffset + k]) { + Snake outSnake = new Snake(); + outSnake.x = backward[kOffset + k]; + outSnake.y = outSnake.x - k; + outSnake.size = forward[kOffset + k] - backward[kOffset + k]; + outSnake.removal = removal; + outSnake.reverse = false; + return outSnake; + } + } + } + for (int k = -d; k <= d; k += 2) { + // find reverse path at k + delta, in reverse + final int backwardK = k + delta; + int x; + final boolean removal; + if (backwardK == d + delta || backwardK != -d + delta + && backward[kOffset + backwardK - 1] < backward[kOffset + backwardK + 1]) { + x = backward[kOffset + backwardK - 1]; + removal = false; + } else { + x = backward[kOffset + backwardK + 1] - 1; + removal = true; + } + + // set y based on x + int y = x - backwardK; + // move diagonal as long as items match + while (x > 0 && y > 0 + && cb.areItemsTheSame(startOld + x - 1, startNew + y - 1)) { + x--; + y--; + } + backward[kOffset + backwardK] = x; + if (!checkInFwd && k + delta >= -d && k + delta <= d) { + if (forward[kOffset + backwardK] >= backward[kOffset + backwardK]) { + Snake outSnake = new Snake(); + outSnake.x = backward[kOffset + backwardK]; + outSnake.y = outSnake.x - backwardK; + outSnake.size = + forward[kOffset + backwardK] - backward[kOffset + backwardK]; + outSnake.removal = removal; + outSnake.reverse = true; + return outSnake; + } + } + } + } + throw new IllegalStateException("DiffUtil hit an unexpected case while trying to calculate" + + " the optimal path. Please make sure your data is not changing during the" + + " diff calculation."); + } + + /** + * A Callback class used by DiffUtil while calculating the diff between two lists. + */ + public abstract static class Callback { + /** + * Returns the size of the old list. + * + * @return The size of the old list. + */ + public abstract int getOldListSize(); + + /** + * Returns the size of the new list. + * + * @return The size of the new list. + */ + public abstract int getNewListSize(); + + /** + * Called by the DiffUtil to decide whether two object represent the same Item. + *

+ * For example, if your items have unique ids, this method should check their id equality. + * + * @param oldItemPosition The position of the item in the old list + * @param newItemPosition The position of the item in the new list + * @return True if the two items represent the same object or false if they are different. + */ + public abstract boolean areItemsTheSame(int oldItemPosition, int newItemPosition); + + /** + * Called by the DiffUtil when it wants to check whether two items have the same data. + * DiffUtil uses this information to detect if the contents of an item has changed. + *

+ * DiffUtil uses this method to check equality instead of {@link Object#equals(Object)} + * so that you can change its behavior depending on your UI. + *

+ * This method is called only if {@link #areItemsTheSame(int, int)} returns + * {@code true} for these items. + * + * @param oldItemPosition The position of the item in the old list + * @param newItemPosition The position of the item in the new list which replaces the + * oldItem + * @return True if the contents of the items are the same or false if they are different. + */ + public abstract boolean areContentsTheSame(int oldItemPosition, int newItemPosition); + + /** + * When {@link #areItemsTheSame(int, int)} returns {@code true} for two items and + * {@link #areContentsTheSame(int, int)} returns false for them, DiffUtil + * calls this method to get a payload about the change. + *

+ * Default implementation returns {@code null}. + * + * @param oldItemPosition The position of the item in the old list + * @param newItemPosition The position of the item in the new list + * + * @return A payload object that represents the change between the two items. + */ + @Nullable + public Object getChangePayload(int oldItemPosition, int newItemPosition) { + return null; + } + } + + /** + * Snakes represent a match between two lists. It is optionally prefixed or postfixed with an + * add or remove operation. See the Myers' paper for details. + */ + static class Snake { + /** + * Position in the old list + */ + int x; + + /** + * Position in the new list + */ + int y; + + /** + * Number of matches. Might be 0. + */ + int size; + + /** + * If true, this is a removal from the original list followed by {@code size} matches. + * If false, this is an addition from the new list followed by {@code size} matches. + */ + boolean removal; + + /** + * If true, the addition or removal is at the end of the snake. + * If false, the addition or removal is at the beginning of the snake. + */ + boolean reverse; + } + + /** + * Represents a range in two lists that needs to be solved. + *

+ * This internal class is used when running Myers' algorithm without recursion. + */ + static class Range { + + int oldListStart, oldListEnd; + + int newListStart, newListEnd; + + public Range() { + } + + public Range(int oldListStart, int oldListEnd, int newListStart, int newListEnd) { + this.oldListStart = oldListStart; + this.oldListEnd = oldListEnd; + this.newListStart = newListStart; + this.newListEnd = newListEnd; + } + } + + /** + * This class holds the information about the result of a + * {@link DiffUtil#calculateDiff(Callback, boolean)} call. + *

+ * You can consume the updates in a DiffResult via + * {@link #dispatchUpdatesTo(ListUpdateCallback)} + */ + public static class DiffResult { + /** + * While reading the flags below, keep in mind that when multiple items move in a list, + * Myers's may pick any of them as the anchor item and consider that one NOT_CHANGED while + * picking others as additions and removals. This is completely fine as we later detect + * all moves. + *

+ * Below, when an item is mentioned to stay in the same "location", it means we won't + * dispatch a move/add/remove for it, it DOES NOT mean the item is still in the same + * position. + */ + // item stayed the same. + private static final int FLAG_NOT_CHANGED = 1; + // item stayed in the same location but changed. + private static final int FLAG_CHANGED = FLAG_NOT_CHANGED << 1; + // Item has moved and also changed. + private static final int FLAG_MOVED_CHANGED = FLAG_CHANGED << 1; + // Item has moved but did not change. + private static final int FLAG_MOVED_NOT_CHANGED = FLAG_MOVED_CHANGED << 1; + // Ignore this update. + // If this is an addition from the new list, it means the item is actually removed from an + // earlier position and its move will be dispatched when we process the matching removal + // from the old list. + // If this is a removal from the old list, it means the item is actually added back to an + // earlier index in the new list and we'll dispatch its move when we are processing that + // addition. + private static final int FLAG_IGNORE = FLAG_MOVED_NOT_CHANGED << 1; + + // since we are re-using the int arrays that were created in the Myers' step, we mask + // change flags + private static final int FLAG_OFFSET = 5; + + private static final int FLAG_MASK = (1 << FLAG_OFFSET) - 1; + + // The Myers' snakes. At this point, we only care about their diagonal sections. + private final List mSnakes; + + // The list to keep oldItemStatuses. As we traverse old items, we assign flags to them + // which also includes whether they were a real removal or a move (and its new index). + private final int[] mOldItemStatuses; + // The list to keep newItemStatuses. As we traverse new items, we assign flags to them + // which also includes whether they were a real addition or a move(and its old index). + private final int[] mNewItemStatuses; + // The callback that was given to calcualte diff method. + private final Callback mCallback; + + private final int mOldListSize; + + private final int mNewListSize; + + private final boolean mDetectMoves; + + /** + * @param callback The callback that was used to calculate the diff + * @param snakes The list of Myers' snakes + * @param oldItemStatuses An int[] that can be re-purposed to keep metadata + * @param newItemStatuses An int[] that can be re-purposed to keep metadata + * @param detectMoves True if this DiffResult will try to detect moved items + */ + DiffResult(Callback callback, List snakes, int[] oldItemStatuses, + int[] newItemStatuses, boolean detectMoves) { + mSnakes = snakes; + mOldItemStatuses = oldItemStatuses; + mNewItemStatuses = newItemStatuses; + Arrays.fill(mOldItemStatuses, 0); + Arrays.fill(mNewItemStatuses, 0); + mCallback = callback; + mOldListSize = callback.getOldListSize(); + mNewListSize = callback.getNewListSize(); + mDetectMoves = detectMoves; + addRootSnake(); + findMatchingItems(); + } + + /** + * We always add a Snake to 0/0 so that we can run loops from end to beginning and be done + * when we run out of snakes. + */ + private void addRootSnake() { + Snake firstSnake = mSnakes.isEmpty() ? null : mSnakes.get(0); + if (firstSnake == null || firstSnake.x != 0 || firstSnake.y != 0) { + Snake root = new Snake(); + root.x = 0; + root.y = 0; + root.removal = false; + root.size = 0; + root.reverse = false; + mSnakes.add(0, root); + } + } + + /** + * This method traverses each addition / removal and tries to match it to a previous + * removal / addition. This is how we detect move operations. + *

+ * This class also flags whether an item has been changed or not. + *

+ * DiffUtil does this pre-processing so that if it is running on a big list, it can be moved + * to background thread where most of the expensive stuff will be calculated and kept in + * the statuses maps. DiffResult uses this pre-calculated information while dispatching + * the updates (which is probably being called on the main thread). + */ + private void findMatchingItems() { + int posOld = mOldListSize; + int posNew = mNewListSize; + // traverse the matrix from right bottom to 0,0. + for (int i = mSnakes.size() - 1; i >= 0; i--) { + final Snake snake = mSnakes.get(i); + final int endX = snake.x + snake.size; + final int endY = snake.y + snake.size; + if (mDetectMoves) { + while (posOld > endX) { + // this is a removal. Check remaining snakes to see if this was added before + findAddition(posOld, posNew, i); + posOld--; + } + while (posNew > endY) { + // this is an addition. Check remaining snakes to see if this was removed + // before + findRemoval(posOld, posNew, i); + posNew--; + } + } + for (int j = 0; j < snake.size; j++) { + // matching items. Check if it is changed or not + final int oldItemPos = snake.x + j; + final int newItemPos = snake.y + j; + final boolean theSame = mCallback + .areContentsTheSame(oldItemPos, newItemPos); + final int changeFlag = theSame ? FLAG_NOT_CHANGED : FLAG_CHANGED; + mOldItemStatuses[oldItemPos] = (newItemPos << FLAG_OFFSET) | changeFlag; + mNewItemStatuses[newItemPos] = (oldItemPos << FLAG_OFFSET) | changeFlag; + } + posOld = snake.x; + posNew = snake.y; + } + } + + private void findAddition(int x, int y, int snakeIndex) { + if (mOldItemStatuses[x - 1] != 0) { + return; // already set by a latter item + } + findMatchingItem(x, y, snakeIndex, false); + } + + private void findRemoval(int x, int y, int snakeIndex) { + if (mNewItemStatuses[y - 1] != 0) { + return; // already set by a latter item + } + findMatchingItem(x, y, snakeIndex, true); + } + + /** + * Finds a matching item that is before the given coordinates in the matrix + * (before : left and above). + * + * @param x The x position in the matrix (position in the old list) + * @param y The y position in the matrix (position in the new list) + * @param snakeIndex The current snake index + * @param removal True if we are looking for a removal, false otherwise + * + * @return True if such item is found. + */ + private boolean findMatchingItem(final int x, final int y, final int snakeIndex, + final boolean removal) { + final int myItemPos; + int curX; + int curY; + if (removal) { + myItemPos = y - 1; + curX = x; + curY = y - 1; + } else { + myItemPos = x - 1; + curX = x - 1; + curY = y; + } + for (int i = snakeIndex; i >= 0; i--) { + final Snake snake = mSnakes.get(i); + final int endX = snake.x + snake.size; + final int endY = snake.y + snake.size; + if (removal) { + // check removals for a match + for (int pos = curX - 1; pos >= endX; pos--) { + if (mCallback.areItemsTheSame(pos, myItemPos)) { + // found! + final boolean theSame = mCallback.areContentsTheSame(pos, myItemPos); + final int changeFlag = theSame ? FLAG_MOVED_NOT_CHANGED + : FLAG_MOVED_CHANGED; + mNewItemStatuses[myItemPos] = (pos << FLAG_OFFSET) | FLAG_IGNORE; + mOldItemStatuses[pos] = (myItemPos << FLAG_OFFSET) | changeFlag; + return true; + } + } + } else { + // check for additions for a match + for (int pos = curY - 1; pos >= endY; pos--) { + if (mCallback.areItemsTheSame(myItemPos, pos)) { + // found + final boolean theSame = mCallback.areContentsTheSame(myItemPos, pos); + final int changeFlag = theSame ? FLAG_MOVED_NOT_CHANGED + : FLAG_MOVED_CHANGED; + mOldItemStatuses[x - 1] = (pos << FLAG_OFFSET) | FLAG_IGNORE; + mNewItemStatuses[pos] = ((x - 1) << FLAG_OFFSET) | changeFlag; + return true; + } + } + } + curX = snake.x; + curY = snake.y; + } + return false; + } + + /** + * Dispatches update operations to the given Callback. + *

+ * These updates are atomic such that the first update call effects every update call that + * comes after it (the same as RecyclerView). + * + * @param updateCallback The callback to receive the update operations. + */ + public void dispatchUpdatesTo(ListUpdateCallback updateCallback) { + final BatchingListUpdateCallback batchingCallback; + if (updateCallback instanceof BatchingListUpdateCallback) { + batchingCallback = (BatchingListUpdateCallback) updateCallback; + } else { + batchingCallback = new BatchingListUpdateCallback(updateCallback); + // replace updateCallback with a batching callback and override references to + // updateCallback so that we don't call it directly by mistake + //noinspection UnusedAssignment + updateCallback = batchingCallback; + } + // These are add/remove ops that are converted to moves. We track their positions until + // their respective update operations are processed. + final List postponedUpdates = new ArrayList<>(); + int posOld = mOldListSize; + int posNew = mNewListSize; + for (int snakeIndex = mSnakes.size() - 1; snakeIndex >= 0; snakeIndex--) { + final Snake snake = mSnakes.get(snakeIndex); + final int snakeSize = snake.size; + final int endX = snake.x + snakeSize; + final int endY = snake.y + snakeSize; + if (endX < posOld) { + dispatchRemovals(postponedUpdates, batchingCallback, endX, posOld - endX, endX); + } + + if (endY < posNew) { + dispatchAdditions(postponedUpdates, batchingCallback, endX, posNew - endY, + endY); + } + for (int i = snakeSize - 1; i >= 0; i--) { + if ((mOldItemStatuses[snake.x + i] & FLAG_MASK) == FLAG_CHANGED) { + batchingCallback.onChanged(snake.x + i, 1, + mCallback.getChangePayload(snake.x + i, snake.y + i)); + } + } + posOld = snake.x; + posNew = snake.y; + } + batchingCallback.dispatchLastEvent(); + } + + private static PostponedUpdate removePostponedUpdate(List updates, + int pos, boolean removal) { + for (int i = updates.size() - 1; i >= 0; i--) { + final PostponedUpdate update = updates.get(i); + if (update.posInOwnerList == pos && update.removal == removal) { + updates.remove(i); + for (int j = i; j < updates.size(); j++) { + // offset other ops since they swapped positions + updates.get(j).currentPos += removal ? 1 : -1; + } + return update; + } + } + return null; + } + + private void dispatchAdditions(List postponedUpdates, + ListUpdateCallback updateCallback, int start, int count, int globalIndex) { + if (!mDetectMoves) { + updateCallback.onInserted(start, count); + return; + } + for (int i = count - 1; i >= 0; i--) { + int status = mNewItemStatuses[globalIndex + i] & FLAG_MASK; + switch (status) { + case 0: // real addition + updateCallback.onInserted(start, 1); + for (PostponedUpdate update : postponedUpdates) { + update.currentPos += 1; + } + break; + case FLAG_MOVED_CHANGED: + case FLAG_MOVED_NOT_CHANGED: + final int pos = mNewItemStatuses[globalIndex + i] >> FLAG_OFFSET; + final PostponedUpdate update = removePostponedUpdate(postponedUpdates, pos, + true); + // the item was moved from that position + //noinspection ConstantConditions + updateCallback.onMoved(update.currentPos, start); + if (status == FLAG_MOVED_CHANGED) { + // also dispatch a change + updateCallback.onChanged(start, 1, + mCallback.getChangePayload(pos, globalIndex + i)); + } + break; + case FLAG_IGNORE: // ignoring this + postponedUpdates.add(new PostponedUpdate(globalIndex + i, start, false)); + break; + default: + throw new IllegalStateException( + "unknown flag for pos " + (globalIndex + i) + " " + Long + .toBinaryString(status)); + } + } + } + + private void dispatchRemovals(List postponedUpdates, + ListUpdateCallback updateCallback, int start, int count, int globalIndex) { + if (!mDetectMoves) { + updateCallback.onRemoved(start, count); + return; + } + for (int i = count - 1; i >= 0; i--) { + final int status = mOldItemStatuses[globalIndex + i] & FLAG_MASK; + switch (status) { + case 0: // real removal + updateCallback.onRemoved(start + i, 1); + for (PostponedUpdate update : postponedUpdates) { + update.currentPos -= 1; + } + break; + case FLAG_MOVED_CHANGED: + case FLAG_MOVED_NOT_CHANGED: + final int pos = mOldItemStatuses[globalIndex + i] >> FLAG_OFFSET; + final PostponedUpdate update = removePostponedUpdate(postponedUpdates, pos, + false); + // the item was moved to that position. we do -1 because this is a move not + // add and removing current item offsets the target move by 1 + //noinspection ConstantConditions + updateCallback.onMoved(start + i, update.currentPos - 1); + if (status == FLAG_MOVED_CHANGED) { + // also dispatch a change + updateCallback.onChanged(update.currentPos - 1, 1, + mCallback.getChangePayload(globalIndex + i, pos)); + } + break; + case FLAG_IGNORE: // ignoring this + postponedUpdates.add(new PostponedUpdate(globalIndex + i, start + i, true)); + break; + default: + throw new IllegalStateException( + "unknown flag for pos " + (globalIndex + i) + " " + Long + .toBinaryString(status)); + } + } + } + } + + /** + * Represents an update that we skipped because it was a move. + *

+ * When an update is skipped, it is tracked as other updates are dispatched until the matching + * add/remove operation is found at which point the tracked position is used to dispatch the + * update. + */ + private static class PostponedUpdate { + + int posInOwnerList; + + int currentPos; + + boolean removal; + + public PostponedUpdate(int posInOwnerList, int currentPos, boolean removal) { + this.posInOwnerList = posInOwnerList; + this.currentPos = currentPos; + this.removal = removal; + } + + } + +} diff --git a/src/main/java/ru/touchin/roboswag/core/android/support/v7/util/ListUpdateCallback.java b/src/main/java/ru/touchin/roboswag/core/android/support/v7/util/ListUpdateCallback.java new file mode 100644 index 0000000..06d9dc0 --- /dev/null +++ b/src/main/java/ru/touchin/roboswag/core/android/support/v7/util/ListUpdateCallback.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ru.touchin.roboswag.core.android.support.v7.util; + +/** + * An interface that can receive Update operations that are applied to a list. + * + *

This class can be used together with DiffUtil to detect changes between two lists. + */ +@SuppressWarnings({"PMD", "checkstyle:all"}) +public interface ListUpdateCallback { + + /** + * Called when {@code count} number of items are inserted at the given position. + * + * @param position The position of the new item. + * @param count The number of items that have been added. + */ + void onInserted(int position, int count); + + /** + * Called when {@code count} number of items are removed from the given position. + * + * @param position The position of the item which has been removed. + * @param count The number of items which have been removed. + */ + void onRemoved(int position, int count); + + /** + * Called when an item changes its position in the list. + * + * @param fromPosition The previous position of the item before the move. + * @param toPosition The new position of the item. + */ + void onMoved(int fromPosition, int toPosition); + + /** + * Called when {@code count} number of items are updated at the given position. + * + * @param position The position of the item which has been updated. + * @param count The number of items which has changed. + */ + void onChanged(int position, int count, Object payload); + +} diff --git a/src/main/java/ru/touchin/roboswag/core/observables/collections/Change.java b/src/main/java/ru/touchin/roboswag/core/observables/collections/Change.java deleted file mode 100644 index 876d25f..0000000 --- a/src/main/java/ru/touchin/roboswag/core/observables/collections/Change.java +++ /dev/null @@ -1,225 +0,0 @@ -/* - * Copyright (c) 2015 RoboSwag (Gavriil Sitnikov, Vsevolod Ivanov) - * - * This file is part of RoboSwag library. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package ru.touchin.roboswag.core.observables.collections; - -import android.support.annotation.NonNull; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; - -/** - * Created by Gavriil Sitnikov on 23/05/16. - * Class representing simple change of collection like insertion, remove or replacing/changing items. - * - * @param Type of changing collection's items. - */ -public class Change { - - /** - * Method to calculate changes between two collections. - * - * @param initialCollection Initial collection; - * @param modifiedCollection Changed collection; - * @param shrinkChangesToModifiedSize Flag to make position of changed items be less then modified collection size. - * It is needed sometimes to not get exceptions like {@link ArrayIndexOutOfBoundsException}. - * @param Type of collections items. - * @return Changes between collections. - */ - @NonNull - public static Collection> calculateCollectionChanges(@NonNull final Collection initialCollection, - @NonNull final Collection modifiedCollection, - final boolean shrinkChangesToModifiedSize) { - return new CollectionsChangesCalculator<>(initialCollection, modifiedCollection, shrinkChangesToModifiedSize).calculateChanges(); - } - - @NonNull - private final Type type; - @NonNull - private final Collection changedItems; - private final int start; - private final int count; - - public Change(@NonNull final Type type, @NonNull final Collection changedItems, final int start) { - this.type = type; - this.changedItems = Collections.unmodifiableCollection(new ArrayList<>(changedItems)); - this.start = start; - this.count = changedItems.size(); - } - - /** - * Returns type of change. - * - * @return Type of change. - */ - @NonNull - public Type getType() { - return type; - } - - /** - * Returns collection of items which this change applied to. - * - * @return Changed items. - */ - @NonNull - public Collection getChangedItems() { - return changedItems; - } - - /** - * Returns first index of changed item. - * - * @return Start of change. - */ - public int getStart() { - return start; - } - - /** - * Returns count of changed items. - * - * @return Count of changed items. - */ - public int getCount() { - return count; - } - - @NonNull - @Override - public String toString() { - return type + " change of " + start + ":" + count; - } - - /** - * Type of change. - */ - public enum Type { - INSERTED, - CHANGED, - REMOVED - } - - private static class CollectionsChangesCalculator { - - @NonNull - private final Collection initialCollection; - @NonNull - private final Collection modifiedCollection; - private final boolean shrinkChangesToModifiedSize; - private int initialOffset; - @NonNull - private final Collection itemsToAdd = new ArrayList<>(); - private int currentSize; - private int oldSize; - private int newSize; - private int couldBeAdded; - - public CollectionsChangesCalculator(@NonNull final Collection initialCollection, - @NonNull final Collection modifiedCollection, - final boolean shrinkChangesToModifiedSize) { - this.initialCollection = initialCollection; - this.modifiedCollection = modifiedCollection; - this.shrinkChangesToModifiedSize = shrinkChangesToModifiedSize; - } - - @NonNull - public Collection> calculateChanges() { - initialOffset = 0; - itemsToAdd.clear(); - currentSize = 0; - oldSize = initialCollection.size(); - newSize = modifiedCollection.size(); - couldBeAdded = modifiedCollection.size() - initialCollection.size(); - final List> result = new ArrayList<>(); - for (final TItem modifiedItem : modifiedCollection) { - int foundPosition = 0; - for (final Object initialObject : initialCollection) { - if (foundPosition >= initialOffset && modifiedItem.equals(initialObject)) { - if (tryAddSkipped(result) == MethodAction.RETURN - || tryRemoveRest(result, initialOffset, foundPosition - initialOffset) == MethodAction.RETURN) { - return result; - } - initialOffset = foundPosition + 1; - currentSize++; - break; - } - foundPosition++; - } - // if not found - if (foundPosition >= initialCollection.size()) { - itemsToAdd.add(modifiedItem); - } - } - - if (tryAddSkipped(result) == MethodAction.RETURN) { - return result; - } - tryRemoveRest(result, initialOffset, initialCollection.size() - initialOffset); - return result; - } - - @NonNull - private MethodAction tryAddSkipped(@NonNull final Collection> changes) { - if (!itemsToAdd.isEmpty()) { - if (shrinkChangesToModifiedSize && couldBeAdded < itemsToAdd.size()) { - addSimpleDifferenceChanges(changes); - return MethodAction.RETURN; - } - changes.add(new Change<>(Type.INSERTED, itemsToAdd, currentSize)); - currentSize += itemsToAdd.size(); - couldBeAdded -= itemsToAdd.size(); - itemsToAdd.clear(); - } - return MethodAction.CONTINUE; - } - - @NonNull - private MethodAction tryRemoveRest(@NonNull final Collection> changes, - final int initialOffset, - final int itemsToRemove) { - if (itemsToRemove > 0) { - if (shrinkChangesToModifiedSize && couldBeAdded < -itemsToRemove) { - addSimpleDifferenceChanges(changes); - return MethodAction.RETURN; - } - changes.add(new Change<>(Change.Type.REMOVED, - new ArrayList<>(initialCollection).subList(initialOffset, initialOffset + itemsToRemove), - currentSize)); - } - return MethodAction.CONTINUE; - } - - private void addSimpleDifferenceChanges(@NonNull final Collection> changes) { - changes.add(new Change<>(Type.CHANGED, new ArrayList<>(modifiedCollection).subList(currentSize, newSize), currentSize)); - if (oldSize - newSize > 0) { - changes.add(new Change<>(Type.REMOVED, new ArrayList<>(initialCollection).subList(newSize, oldSize), newSize)); - } - } - - private enum MethodAction { - RETURN, - CONTINUE - } - - } - -} \ No newline at end of file diff --git a/src/main/java/ru/touchin/roboswag/core/observables/collections/ObservableCollection.java b/src/main/java/ru/touchin/roboswag/core/observables/collections/ObservableCollection.java index d759085..c6e4057 100644 --- a/src/main/java/ru/touchin/roboswag/core/observables/collections/ObservableCollection.java +++ b/src/main/java/ru/touchin/roboswag/core/observables/collections/ObservableCollection.java @@ -27,7 +27,10 @@ import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.util.Collection; import java.util.Collections; +import java.util.List; +import ru.touchin.roboswag.core.observables.collections.changes.Change; +import ru.touchin.roboswag.core.observables.collections.changes.CollectionChanges; import rx.Emitter; import rx.Observable; @@ -43,11 +46,11 @@ public abstract class ObservableCollection { private int changesCount; @NonNull - private transient Observable> changesObservable; + private transient Observable> changesObservable; @NonNull private transient Observable> itemsObservable; @Nullable - private transient Emitter> changesEmitter; + private transient Emitter> changesEmitter; public ObservableCollection() { this.changesObservable = createChangesObservable(); @@ -55,9 +58,9 @@ public abstract class ObservableCollection { } @NonNull - private Observable> createChangesObservable() { + private Observable> createChangesObservable() { return Observable - .>create(emitter -> this.changesEmitter = emitter, Emitter.BackpressureMode.BUFFER) + .>create(emitter -> this.changesEmitter = emitter, Emitter.BackpressureMode.BUFFER) .doOnUnsubscribe(() -> this.changesEmitter = null) .share(); } @@ -85,22 +88,28 @@ public abstract class ObservableCollection { * * @param change Change of collection. */ - protected void notifyAboutChange(@NonNull final Change change) { - notifyAboutChanges(Collections.singleton(change)); + protected void notifyAboutChange(@NonNull final List insertedItems, + @NonNull final List removedItems, + @NonNull final Change change) { + notifyAboutChanges(insertedItems, removedItems, Collections.singleton(change)); } /** * Method to notify that collection have changed. * + * @param insertedItems Collection of inserted items; + * @param removedItems Collection of removed items; * @param changes Changes of collection. */ - protected void notifyAboutChanges(@NonNull final Collection> changes) { + protected void notifyAboutChanges(@NonNull final List insertedItems, + @NonNull final List removedItems, + @NonNull final Collection changes) { if (changes.isEmpty()) { return; } changesCount++; if (changesEmitter != null) { - changesEmitter.onNext(new CollectionChange<>(changesCount, Collections.unmodifiableCollection(changes))); + changesEmitter.onNext(new CollectionChanges<>(changesCount, insertedItems, removedItems, changes)); } } @@ -110,7 +119,7 @@ public abstract class ObservableCollection { * @return List of changes applied to collection. */ @NonNull - public Observable> observeChanges() { + public Observable> observeChanges() { return changesObservable; } @@ -170,41 +179,4 @@ public abstract class ObservableCollection { this.itemsObservable = createItemsObservable(); } - /** - * Class which is representing change of collection. There could be multiple changes applied to collection. - * - * @param Type of collection's items. - */ - public static class CollectionChange { - - private final int number; - @NonNull - private final Collection> changes; - - protected CollectionChange(final int number, @NonNull final Collection> changes) { - this.number = number; - this.changes = changes; - } - - /** - * Returns number of change. - * - * @return Number of change. - */ - public int getNumber() { - return number; - } - - /** - * Returns collection of changes. - * - * @return Collection of changes. - */ - @NonNull - public Collection> getChanges() { - return changes; - } - - } - } diff --git a/src/main/java/ru/touchin/roboswag/core/observables/collections/ObservableFilteredList.java b/src/main/java/ru/touchin/roboswag/core/observables/collections/ObservableFilteredList.java index 6898560..158ccf3 100644 --- a/src/main/java/ru/touchin/roboswag/core/observables/collections/ObservableFilteredList.java +++ b/src/main/java/ru/touchin/roboswag/core/observables/collections/ObservableFilteredList.java @@ -9,6 +9,7 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.Executors; +import ru.touchin.roboswag.core.observables.collections.changes.DefaultCollectionsChangesCalculator; import rx.Scheduler; import rx.Subscription; import rx.functions.Func1; @@ -23,6 +24,9 @@ import rx.schedulers.Schedulers; */ public class ObservableFilteredList extends ObservableCollection { + // we need to filter on 1 thread to prevent parallel filtering + private static final Scheduler FILTER_SCHEDULER = Schedulers.from(Executors.newSingleThreadExecutor()); + @NonNull private static List filterCollection(@NonNull final Collection sourceCollection, @Nullable final Func1 filter) { @@ -38,9 +42,6 @@ public class ObservableFilteredList extends ObservableCollection { return result; } - // we need to filter on 1 thread to prevent parallel filtering - @NonNull - private final Scheduler filterScheduler = Schedulers.from(Executors.newSingleThreadExecutor()); @NonNull private List filteredList; @NonNull @@ -106,11 +107,13 @@ public class ObservableFilteredList extends ObservableCollection { sourceCollectionSubscription = null; } sourceCollectionSubscription = sourceCollection.observeItems() - .observeOn(filterScheduler) + .observeOn(FILTER_SCHEDULER) .subscribe(items -> { final List oldFilteredList = filteredList; filteredList = filterCollection(items, filter); - notifyAboutChanges(Change.calculateCollectionChanges(oldFilteredList, filteredList, false)); + final DefaultCollectionsChangesCalculator calculator + = new DefaultCollectionsChangesCalculator<>(oldFilteredList, filteredList, false); + notifyAboutChanges(calculator.calculateInsertedItems(), calculator.calculateRemovedItems(), calculator.calculateChanges()); }); } diff --git a/src/main/java/ru/touchin/roboswag/core/observables/collections/ObservableList.java b/src/main/java/ru/touchin/roboswag/core/observables/collections/ObservableList.java index 5610c64..2c08c31 100644 --- a/src/main/java/ru/touchin/roboswag/core/observables/collections/ObservableList.java +++ b/src/main/java/ru/touchin/roboswag/core/observables/collections/ObservableList.java @@ -20,6 +20,7 @@ package ru.touchin.roboswag.core.observables.collections; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import java.io.IOException; import java.io.ObjectInputStream; @@ -31,6 +32,12 @@ import java.util.Collections; import java.util.List; import ru.touchin.roboswag.core.log.Lc; +import ru.touchin.roboswag.core.observables.collections.changes.Change; +import ru.touchin.roboswag.core.observables.collections.changes.ChangePayloadProducer; +import ru.touchin.roboswag.core.observables.collections.changes.CollectionsChangesCalculator; +import ru.touchin.roboswag.core.observables.collections.changes.DefaultCollectionsChangesCalculator; +import ru.touchin.roboswag.core.observables.collections.changes.DiffCollectionsChangesCalculator; +import ru.touchin.roboswag.core.observables.collections.changes.SameItemsPredicate; /** * Created by Gavriil Sitnikov on 23/05/16. @@ -46,6 +53,11 @@ public class ObservableList extends ObservableCollection implement @NonNull private List items; + private boolean detectMoves; + @Nullable + private SameItemsPredicate sameItemsPredicate; + @Nullable + private ChangePayloadProducer changePayloadProducer; public ObservableList() { super(); @@ -75,7 +87,7 @@ public class ObservableList extends ObservableCollection implement public void add(final int position, @NonNull final TItem item) { synchronized (this) { items.add(position, item); - notifyAboutChange(new Change<>(Change.Type.INSERTED, Collections.singleton(item), position)); + notifyAboutChange(Collections.singletonList(item), Collections.emptyList(), new Change.Inserted(position, 1)); } } @@ -98,7 +110,7 @@ public class ObservableList extends ObservableCollection implement synchronized (this) { if (!itemsToAdd.isEmpty()) { items.addAll(position, itemsToAdd); - notifyAboutChange(new Change<>(Change.Type.INSERTED, itemsToAdd, position)); + notifyAboutChange(new ArrayList<>(itemsToAdd), Collections.emptyList(), new Change.Inserted(position, itemsToAdd.size())); } } } @@ -139,12 +151,11 @@ public class ObservableList extends ObservableCollection implement return; } synchronized (this) { - final List changedItems = new ArrayList<>(count); + final List removedItems = new ArrayList<>(count); for (int i = 0; i < count; i++) { - changedItems.add(items.get(position)); - items.remove(position); + removedItems.add(items.remove(position)); } - notifyAboutChange(new Change<>(Change.Type.REMOVED, changedItems, position)); + notifyAboutChange(Collections.emptyList(), removedItems, new Change.Removed(position, count)); } } @@ -153,10 +164,10 @@ public class ObservableList extends ObservableCollection implement */ public void clear() { synchronized (this) { - final Change change = new Change<>(Change.Type.REMOVED, items, 0); - if (!change.getChangedItems().isEmpty()) { + if (!items.isEmpty()) { + final List removedItems = new ArrayList<>(items); items.clear(); - notifyAboutChange(change); + notifyAboutChange(Collections.emptyList(), removedItems, new Change.Removed(0, removedItems.size())); } } } @@ -203,7 +214,7 @@ public class ObservableList extends ObservableCollection implement items.set(index, item); index++; } - notifyAboutChange(new Change<>(Change.Type.CHANGED, updatedItems, position)); + notifyAboutChange(Collections.emptyList(), Collections.emptyList(), new Change.Changed(position, updatedItems.size(), null)); } } @@ -214,10 +225,14 @@ public class ObservableList extends ObservableCollection implement */ public void set(@NonNull final Collection newItems) { synchronized (this) { - final Collection> changes = Change.calculateCollectionChanges(items, newItems, false); + final List oldList = new ArrayList<>(items); + final List newList = new ArrayList<>(newItems); + final CollectionsChangesCalculator calculator = sameItemsPredicate != null + ? new DiffCollectionsChangesCalculator<>(oldList, newList, detectMoves, sameItemsPredicate, changePayloadProducer) + : new DefaultCollectionsChangesCalculator<>(oldList, newList, false); items.clear(); items.addAll(newItems); - notifyAboutChanges(changes); + notifyAboutChanges(calculator.calculateInsertedItems(), calculator.calculateRemovedItems(), calculator.calculateChanges()); } } @@ -228,6 +243,37 @@ public class ObservableList extends ObservableCollection implement } } + /** + * Enable diff utils algorithm in collection changes. + * + * @param detectMoves The flag that determines whether the {@link Change.Moved} changes will be generated or not; + * @param sameItemsPredicate Predicate for the determination of the same elements; + * @param changePayloadProducer Function that calculate change payload when items the same but contents are different. + */ + public void enableDiffUtils(final boolean detectMoves, + @NonNull final SameItemsPredicate sameItemsPredicate, + @Nullable final ChangePayloadProducer changePayloadProducer) { + this.detectMoves = detectMoves; + this.sameItemsPredicate = sameItemsPredicate; + this.changePayloadProducer = changePayloadProducer; + } + + /** + * Disable diff utils algorithm. + */ + public void disableDiffUtils() { + this.sameItemsPredicate = null; + } + + /** + * Returns enabled flag of diff utils. + * + * @return true if diff utils is enabled. + */ + public boolean diffUtilsIsEnabled() { + return sameItemsPredicate != null; + } + /** * Returns position of item in list. * diff --git a/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/Change.java b/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/Change.java new file mode 100644 index 0000000..a1b1c80 --- /dev/null +++ b/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/Change.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2015 RoboSwag (Gavriil Sitnikov, Vsevolod Ivanov) + * + * This file is part of RoboSwag library. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package ru.touchin.roboswag.core.observables.collections.changes; + +import android.support.annotation.Nullable; + +/** + * Created by Gavriil Sitnikov on 23/05/16. + * Class representing simple change of collection like insertion, remove or replacing/changing items. + */ +public abstract class Change { + + /** + * Represents a insert operation in collection. + */ + public static class Inserted extends Change { + + private final int position; + private final int count; + + public Inserted(final int position, final int count) { + super(); + this.position = position; + this.count = count; + } + + public int getPosition() { + return position; + } + + public int getCount() { + return count; + } + + } + + /** + * Represents a remove operation from collection. + */ + public static class Removed extends Change { + + private final int position; + private final int count; + + public Removed(final int position, final int count) { + super(); + this.position = position; + this.count = count; + } + + public int getPosition() { + return position; + } + + public int getCount() { + return count; + } + + } + + /** + * Represents a move operation in collection. + */ + public static class Moved extends Change { + + private final int fromPosition; + private final int toPosition; + + public Moved(final int fromPosition, final int toPosition) { + super(); + this.fromPosition = fromPosition; + this.toPosition = toPosition; + } + + public int getFromPosition() { + return fromPosition; + } + + public int getToPosition() { + return toPosition; + } + + } + + /** + * Represents a modification operation in a collection. + */ + public static class Changed extends Change { + + private final int position; + private final int count; + @Nullable + private final Object payload; + + public Changed(final int position, final int count, @Nullable final Object payload) { + super(); + this.position = position; + this.count = count; + this.payload = payload; + } + + public int getPosition() { + return position; + } + + public int getCount() { + return count; + } + + @Nullable + public Object getPayload() { + return payload; + } + + } + +} diff --git a/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/ChangePayloadProducer.java b/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/ChangePayloadProducer.java new file mode 100644 index 0000000..78f4319 --- /dev/null +++ b/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/ChangePayloadProducer.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2017 RoboSwag (Gavriil Sitnikov, Vsevolod Ivanov) + * + * This file is part of RoboSwag library. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package ru.touchin.roboswag.core.observables.collections.changes; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +public interface ChangePayloadProducer { + + @Nullable + Object getChangePayload(@NonNull TItem item1, @NonNull TItem item2); + +} diff --git a/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/CollectionChanges.java b/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/CollectionChanges.java new file mode 100644 index 0000000..f161155 --- /dev/null +++ b/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/CollectionChanges.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2017 RoboSwag (Gavriil Sitnikov, Vsevolod Ivanov) + * + * This file is part of RoboSwag library. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package ru.touchin.roboswag.core.observables.collections.changes; + +import android.support.annotation.NonNull; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * Class which is representing change of collection. There could be multiple changes applied to collection. + */ +public class CollectionChanges { + + private final int number; + @NonNull + private final List insertedItems; + @NonNull + private final List removedItems; + @NonNull + private final Collection changes; + + public CollectionChanges(final int number, + @NonNull final List insertedItems, + @NonNull final List removedItems, + @NonNull final Collection changes) { + this.number = number; + this.insertedItems = Collections.unmodifiableList(insertedItems); + this.removedItems = Collections.unmodifiableList(removedItems); + this.changes = Collections.unmodifiableCollection(changes); + } + + /** + * Returns number of change. + * + * @return Number of change. + */ + public int getNumber() { + return number; + } + + /** + * Returns collection of changes. + * + * @return Collection of changes. + */ + @NonNull + public Collection getChanges() { + return changes; + } + + /** + * Returns inserted items in change. + * + * @return Inserted items. + */ + @NonNull + public List getInsertedItems() { + return insertedItems; + } + + /** + * Returns removed items in change. + * + * @return Removed items. + */ + @NonNull + public List getRemovedItems() { + return removedItems; + } + +} diff --git a/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/CollectionsChangesCalculator.java b/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/CollectionsChangesCalculator.java new file mode 100644 index 0000000..d86147d --- /dev/null +++ b/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/CollectionsChangesCalculator.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2017 RoboSwag (Gavriil Sitnikov, Vsevolod Ivanov) + * + * This file is part of RoboSwag library. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package ru.touchin.roboswag.core.observables.collections.changes; + +import android.support.annotation.NonNull; + +import java.util.List; + +public interface CollectionsChangesCalculator { + + @NonNull + List calculateChanges(); + + @NonNull + List calculateInsertedItems(); + + @NonNull + List calculateRemovedItems(); + +} diff --git a/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/DefaultCollectionsChangesCalculator.java b/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/DefaultCollectionsChangesCalculator.java new file mode 100644 index 0000000..883fed8 --- /dev/null +++ b/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/DefaultCollectionsChangesCalculator.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2017 RoboSwag (Gavriil Sitnikov, Vsevolod Ivanov) + * + * This file is part of RoboSwag library. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package ru.touchin.roboswag.core.observables.collections.changes; + +import android.support.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class DefaultCollectionsChangesCalculator implements CollectionsChangesCalculator { + + @NonNull + private final Collection initialCollection; + @NonNull + private final Collection modifiedCollection; + private final boolean shrinkChangesToModifiedSize; + @NonNull + private final Collection itemsToAdd = new ArrayList<>(); + private int currentSize; + private int oldSize; + private int newSize; + private int couldBeAdded; + + /** + * Default calculator of changes between two collections. + * + * @param initialCollection Initial collection; + * @param modifiedCollection Changed collection; + * @param shrinkChangesToModifiedSize Flag to make position of changed items be less then modified collection size. + * It is needed sometimes to not get exceptions like {@link ArrayIndexOutOfBoundsException}. + */ + public DefaultCollectionsChangesCalculator(@NonNull final Collection initialCollection, + @NonNull final Collection modifiedCollection, + final boolean shrinkChangesToModifiedSize) { + super(); + this.initialCollection = initialCollection; + this.modifiedCollection = modifiedCollection; + this.shrinkChangesToModifiedSize = shrinkChangesToModifiedSize; + } + + @NonNull + @Override + public List calculateChanges() { + int initialOffset = 0; + itemsToAdd.clear(); + currentSize = 0; + oldSize = initialCollection.size(); + newSize = modifiedCollection.size(); + couldBeAdded = modifiedCollection.size() - initialCollection.size(); + final List result = new ArrayList<>(); + for (final TItem modifiedItem : modifiedCollection) { + int foundPosition = 0; + for (final Object initialObject : initialCollection) { + if (foundPosition >= initialOffset && modifiedItem.equals(initialObject)) { + if (tryAddSkipped(result) == MethodAction.RETURN + || tryRemoveRest(result, foundPosition - initialOffset) == MethodAction.RETURN) { + return result; + } + initialOffset = foundPosition + 1; + currentSize++; + break; + } + foundPosition++; + } + // if not found + if (foundPosition >= initialCollection.size()) { + itemsToAdd.add(modifiedItem); + } + } + + if (tryAddSkipped(result) == MethodAction.RETURN) { + return result; + } + tryRemoveRest(result, initialCollection.size() - initialOffset); + return result; + } + + @NonNull + @Override + public List calculateInsertedItems() { + final List insertedItems = new ArrayList<>(); + for (final TItem newItem : modifiedCollection) { + if (!initialCollection.contains(newItem)) { + insertedItems.add(newItem); + } + } + return insertedItems; + } + + @NonNull + @Override + public List calculateRemovedItems() { + final List removedItems = new ArrayList<>(); + for (final TItem oldItem : initialCollection) { + if (!modifiedCollection.contains(oldItem)) { + removedItems.add(oldItem); + } + } + return removedItems; + } + + @NonNull + private MethodAction tryAddSkipped(@NonNull final Collection changes) { + if (!itemsToAdd.isEmpty()) { + if (shrinkChangesToModifiedSize && couldBeAdded < itemsToAdd.size()) { + addSimpleDifferenceChanges(changes); + return MethodAction.RETURN; + } + changes.add(new Change.Inserted(currentSize, itemsToAdd.size())); + currentSize += itemsToAdd.size(); + couldBeAdded -= itemsToAdd.size(); + itemsToAdd.clear(); + } + return MethodAction.CONTINUE; + } + + @NonNull + private MethodAction tryRemoveRest(@NonNull final Collection changes, final int itemsToRemove) { + if (itemsToRemove > 0) { + if (shrinkChangesToModifiedSize && couldBeAdded < -itemsToRemove) { + addSimpleDifferenceChanges(changes); + return MethodAction.RETURN; + } + changes.add(new Change.Removed(currentSize, itemsToRemove)); + } + return MethodAction.CONTINUE; + } + + private void addSimpleDifferenceChanges(@NonNull final Collection changes) { + changes.add(new Change.Changed(currentSize, newSize - currentSize, null)); + if (oldSize - newSize > 0) { + changes.add(new Change.Removed(newSize, oldSize - newSize)); + } + } + + private enum MethodAction { + RETURN, + CONTINUE + } + +} diff --git a/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/DiffCollectionsChangesCalculator.java b/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/DiffCollectionsChangesCalculator.java new file mode 100644 index 0000000..9115e6c --- /dev/null +++ b/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/DiffCollectionsChangesCalculator.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2017 RoboSwag (Gavriil Sitnikov, Vsevolod Ivanov) + * + * This file is part of RoboSwag library. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package ru.touchin.roboswag.core.observables.collections.changes; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import java.util.ArrayList; +import java.util.List; + +import ru.touchin.roboswag.core.android.support.v7.util.DiffUtil; +import ru.touchin.roboswag.core.android.support.v7.util.ListUpdateCallback; + +public class DiffCollectionsChangesCalculator extends DiffUtil.Callback implements CollectionsChangesCalculator { + + @NonNull + private final List oldList; + @NonNull + private final List newList; + private final boolean detectMoves; + @NonNull + private final SameItemsPredicate sameItemsPredicate; + @Nullable + private final ChangePayloadProducer changePayloadProducer; + + public DiffCollectionsChangesCalculator(@NonNull final List oldList, + @NonNull final List newList, + final boolean detectMoves, + @NonNull final SameItemsPredicate sameItemsPredicate, + @Nullable final ChangePayloadProducer changePayloadProducer) { + super(); + this.oldList = oldList; + this.newList = newList; + this.detectMoves = detectMoves; + this.sameItemsPredicate = sameItemsPredicate; + this.changePayloadProducer = changePayloadProducer; + } + + @NonNull + @Override + public List calculateChanges() { + final List changes = new ArrayList<>(); + DiffUtil.calculateDiff(this, detectMoves).dispatchUpdatesTo(new ListUpdateCallback() { + @Override + public void onInserted(final int position, final int count) { + changes.add(new Change.Inserted(position, count)); + } + + @Override + public void onRemoved(final int position, final int count) { + changes.add(new Change.Removed(position, count)); + } + + @Override + public void onMoved(final int fromPosition, final int toPosition) { + changes.add(new Change.Moved(fromPosition, toPosition)); + } + + @Override + public void onChanged(final int position, final int count, @Nullable final Object payload) { + changes.add(new Change.Changed(position, count, payload)); + } + }); + return changes; + } + + @NonNull + @Override + public List calculateInsertedItems() { + final List insertedItems = new ArrayList<>(); + for (final TItem newItem : newList) { + if (!containsByPredicate(newItem, oldList)) { + insertedItems.add(newItem); + } + } + return insertedItems; + } + + @NonNull + @Override + public List calculateRemovedItems() { + final List removedItems = new ArrayList<>(); + for (final TItem oldItem : oldList) { + if (!containsByPredicate(oldItem, newList)) { + removedItems.add(oldItem); + } + } + return removedItems; + } + + @Override + public int getOldListSize() { + return oldList.size(); + } + + @Override + public int getNewListSize() { + return newList.size(); + } + + @Override + public boolean areItemsTheSame(final int oldItemPosition, final int newItemPosition) { + return sameItemsPredicate.areSame(oldList.get(oldItemPosition), newList.get(newItemPosition)); + } + + @Override + public boolean areContentsTheSame(final int oldItemPosition, final int newItemPosition) { + return oldList.get(oldItemPosition).equals(newList.get(newItemPosition)); + } + + @Nullable + @Override + public Object getChangePayload(final int oldItemPosition, final int newItemPosition) { + return changePayloadProducer != null + ? changePayloadProducer.getChangePayload(oldList.get(oldItemPosition), newList.get(newItemPosition)) : null; + } + + private boolean containsByPredicate(@NonNull final TItem searchedItem, @NonNull final List items) { + for (final TItem item : items) { + if (sameItemsPredicate.areSame(item, searchedItem)) { + return true; + } + } + return false; + } + +} diff --git a/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/SameItemsPredicate.java b/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/SameItemsPredicate.java new file mode 100644 index 0000000..1cb03da --- /dev/null +++ b/src/main/java/ru/touchin/roboswag/core/observables/collections/changes/SameItemsPredicate.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2017 RoboSwag (Gavriil Sitnikov, Vsevolod Ivanov) + * + * This file is part of RoboSwag library. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package ru.touchin.roboswag.core.observables.collections.changes; + +import android.support.annotation.NonNull; + +public interface SameItemsPredicate { + + boolean areSame(@NonNull TItem item1, @NonNull TItem item2); + +} diff --git a/src/main/java/ru/touchin/roboswag/core/observables/collections/loadable/LoadingMoreList.java b/src/main/java/ru/touchin/roboswag/core/observables/collections/loadable/LoadingMoreList.java index 38412ee..7f8092e 100644 --- a/src/main/java/ru/touchin/roboswag/core/observables/collections/loadable/LoadingMoreList.java +++ b/src/main/java/ru/touchin/roboswag/core/observables/collections/loadable/LoadingMoreList.java @@ -30,9 +30,10 @@ import java.util.NoSuchElementException; import java.util.concurrent.Executors; import ru.touchin.roboswag.core.log.Lc; -import ru.touchin.roboswag.core.observables.collections.Change; import ru.touchin.roboswag.core.observables.collections.ObservableCollection; import ru.touchin.roboswag.core.observables.collections.ObservableList; +import ru.touchin.roboswag.core.observables.collections.changes.Change; +import ru.touchin.roboswag.core.observables.collections.changes.CollectionChanges; import ru.touchin.roboswag.core.utils.ShouldNotHappenException; import rx.Observable; import rx.Scheduler; @@ -135,12 +136,14 @@ public class LoadingMoreList> observeChanges() { + public Observable> observeChanges() { return innerList.observeChanges(); } @Override - protected void notifyAboutChanges(@NonNull final Collection> changes) { + protected void notifyAboutChanges(@NonNull final List insertedItems, + @NonNull final List removedItems, + @NonNull final Collection changes) { Lc.assertion("Illegal operation. Modify getInnerList()"); }